mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
feat: add session guard
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AUTH_SESSION_COOKIE_NAME,
|
||||
AuthError,
|
||||
getCurrentUser,
|
||||
hashSessionToken,
|
||||
parseCookieHeader,
|
||||
requireAdmin,
|
||||
requireUser,
|
||||
} from '../worker/auth';
|
||||
import type { Env } from '../worker/env';
|
||||
|
||||
type SessionRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image_url: string | null;
|
||||
role: 'user' | 'admin';
|
||||
expires_at: string;
|
||||
session_token_hash: string;
|
||||
};
|
||||
|
||||
class FakeSessionD1 {
|
||||
constructor(private readonly rows: SessionRow[]) {}
|
||||
|
||||
prepare(sql: string): FakeSessionStatement {
|
||||
return new FakeSessionStatement(this.rows, sql);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSessionStatement {
|
||||
private params: unknown[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly rows: SessionRow[],
|
||||
private readonly sql: string,
|
||||
) {}
|
||||
|
||||
bind(...params: unknown[]): this {
|
||||
this.params = params;
|
||||
return this;
|
||||
}
|
||||
|
||||
async first<T>(): Promise<T | null> {
|
||||
expect(this.sql).toContain('session_token_hash');
|
||||
expect(this.sql).toContain('JOIN users');
|
||||
expect(this.sql).toContain('expires_at');
|
||||
|
||||
const hash = String(this.params[0]);
|
||||
const row = this.rows.find((candidate) => candidate.session_token_hash === hash);
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
image_url: row.image_url,
|
||||
role: row.role,
|
||||
expires_at: row.expires_at,
|
||||
} as T;
|
||||
}
|
||||
}
|
||||
|
||||
function makeEnv(rows: SessionRow[] = []): { env: Env; db: FakeSessionD1 } {
|
||||
const db = new FakeSessionD1(rows);
|
||||
return { env: { DB: db as unknown as D1Database }, db };
|
||||
}
|
||||
|
||||
function makeRequest(cookieHeader?: string | null): Request {
|
||||
const headers = new Headers();
|
||||
if (cookieHeader != null) {
|
||||
headers.set('cookie', cookieHeader);
|
||||
}
|
||||
return new Request('https://my.heygo.cc/api/me', { headers });
|
||||
}
|
||||
|
||||
function futureIso(): string {
|
||||
return new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function pastIso(): string {
|
||||
return new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
describe('parseCookieHeader', () => {
|
||||
it('returns empty object for null/undefined/empty', () => {
|
||||
expect(parseCookieHeader(null)).toEqual({});
|
||||
expect(parseCookieHeader(undefined)).toEqual({});
|
||||
expect(parseCookieHeader('')).toEqual({});
|
||||
});
|
||||
|
||||
it('parses multiple cookies', () => {
|
||||
expect(parseCookieHeader('a=1; b=2; c=hello')).toEqual({
|
||||
a: '1',
|
||||
b: '2',
|
||||
c: 'hello',
|
||||
});
|
||||
});
|
||||
|
||||
it('trims whitespace around keys and values', () => {
|
||||
expect(parseCookieHeader(' a = 1 ; b = 2 ')).toEqual({
|
||||
a: '1',
|
||||
b: '2',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashSessionToken', () => {
|
||||
it('is deterministic and matches known SHA-256 for "hello"', async () => {
|
||||
const hash = await hashSessionToken('hello');
|
||||
expect(hash).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824');
|
||||
});
|
||||
|
||||
it('returns lowercase hex', async () => {
|
||||
const hash = await hashSessionToken('SomeMixedCaseToken!');
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('returns null when there is no Cookie header', async () => {
|
||||
const { env } = makeEnv();
|
||||
const user = await getCurrentUser(makeRequest(), env);
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the session cookie is missing', async () => {
|
||||
const { env } = makeEnv();
|
||||
const user = await getCurrentUser(makeRequest('other=1'), env);
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the session token has no matching session row', async () => {
|
||||
const { env } = makeEnv();
|
||||
const cookie = `${AUTH_SESSION_COOKIE_NAME}=unknown-token`;
|
||||
const user = await getCurrentUser(makeRequest(cookie), env);
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the user for a valid, non-expired session', async () => {
|
||||
const token = 'valid-session-token';
|
||||
const hash = await hashSessionToken(token);
|
||||
const { env } = makeEnv([
|
||||
{
|
||||
id: 'user_1',
|
||||
email: 'admin@heygo.cc',
|
||||
name: 'Admin',
|
||||
image_url: 'https://example.com/avatar.png',
|
||||
role: 'admin',
|
||||
expires_at: futureIso(),
|
||||
session_token_hash: hash,
|
||||
},
|
||||
]);
|
||||
|
||||
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
|
||||
const user = await getCurrentUser(makeRequest(cookie), env);
|
||||
|
||||
expect(user).toEqual({
|
||||
id: 'user_1',
|
||||
email: 'admin@heygo.cc',
|
||||
name: 'Admin',
|
||||
imageUrl: 'https://example.com/avatar.png',
|
||||
role: 'admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for an expired session', async () => {
|
||||
const token = 'expired-session-token';
|
||||
const hash = await hashSessionToken(token);
|
||||
const { env } = makeEnv([
|
||||
{
|
||||
id: 'user_2',
|
||||
email: 'user@heygo.cc',
|
||||
name: 'User',
|
||||
image_url: null,
|
||||
role: 'user',
|
||||
expires_at: pastIso(),
|
||||
session_token_hash: hash,
|
||||
},
|
||||
]);
|
||||
|
||||
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
|
||||
const user = await getCurrentUser(makeRequest(cookie), env);
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireUser', () => {
|
||||
it('throws AuthError when unauthenticated', async () => {
|
||||
const { env } = makeEnv();
|
||||
await expect(requireUser(makeRequest(), env)).rejects.toThrow(AuthError);
|
||||
await expect(requireUser(makeRequest(), env)).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('returns the user when authenticated', async () => {
|
||||
const token = 'valid-user-token';
|
||||
const hash = await hashSessionToken(token);
|
||||
const { env } = makeEnv([
|
||||
{
|
||||
id: 'user_3',
|
||||
email: 'person@heygo.cc',
|
||||
name: 'Person',
|
||||
image_url: null,
|
||||
role: 'user',
|
||||
expires_at: futureIso(),
|
||||
session_token_hash: hash,
|
||||
},
|
||||
]);
|
||||
|
||||
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
|
||||
const user = await requireUser(makeRequest(cookie), env);
|
||||
expect(user.id).toBe('user_3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requireAdmin', () => {
|
||||
it('rejects an unauthenticated request', async () => {
|
||||
const { env } = makeEnv();
|
||||
await expect(requireAdmin(makeRequest(), env)).rejects.toThrow(AuthError);
|
||||
await expect(requireAdmin(makeRequest(), env)).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it('rejects a normal (non-admin) user', async () => {
|
||||
const token = 'normal-user-token';
|
||||
const hash = await hashSessionToken(token);
|
||||
const { env } = makeEnv([
|
||||
{
|
||||
id: 'user_4',
|
||||
email: 'normal@heygo.cc',
|
||||
name: 'Normal',
|
||||
image_url: null,
|
||||
role: 'user',
|
||||
expires_at: futureIso(),
|
||||
session_token_hash: hash,
|
||||
},
|
||||
]);
|
||||
|
||||
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
|
||||
await expect(requireAdmin(makeRequest(cookie), env)).rejects.toThrow(AuthError);
|
||||
await expect(requireAdmin(makeRequest(cookie), env)).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('returns the admin user for an admin session', async () => {
|
||||
const token = 'admin-session-token';
|
||||
const hash = await hashSessionToken(token);
|
||||
const { env } = makeEnv([
|
||||
{
|
||||
id: 'user_5',
|
||||
email: 'boss@heygo.cc',
|
||||
name: 'Boss',
|
||||
image_url: null,
|
||||
role: 'admin',
|
||||
expires_at: futureIso(),
|
||||
session_token_hash: hash,
|
||||
},
|
||||
]);
|
||||
|
||||
const cookie = `${AUTH_SESSION_COOKIE_NAME}=${token}`;
|
||||
const user = await requireAdmin(makeRequest(cookie), env);
|
||||
expect(user.id).toBe('user_5');
|
||||
expect(user.role).toBe('admin');
|
||||
});
|
||||
});
|
||||
+118
@@ -1,3 +1,5 @@
|
||||
import type { Env } from './env';
|
||||
|
||||
export const AUTH_SESSION_COOKIE_NAME = 'heygo_session' as const;
|
||||
|
||||
export const AUTH_ENABLED_PROVIDERS = ['google', 'github'] as const;
|
||||
@@ -41,3 +43,119 @@ export function getSessionCookieAttributes(requestUrl: string | URL): string[] {
|
||||
function isHeygoProductionHost(hostname: string): boolean {
|
||||
return hostname === 'heygo.cc' || hostname.endsWith('.heygo.cc');
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'AuthError';
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
toResponse(): Response {
|
||||
return Response.json({ error: this.message }, { status: this.status });
|
||||
}
|
||||
}
|
||||
|
||||
export function parseCookieHeader(cookieHeader: string | null | undefined): Record<string, string> {
|
||||
const cookies: Record<string, string> = {};
|
||||
|
||||
if (!cookieHeader) {
|
||||
return cookies;
|
||||
}
|
||||
|
||||
for (const part of cookieHeader.split(';')) {
|
||||
const equalsIndex = part.indexOf('=');
|
||||
if (equalsIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = part.slice(0, equalsIndex).trim();
|
||||
const value = part.slice(equalsIndex + 1).trim();
|
||||
|
||||
if (key) {
|
||||
cookies[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
export async function hashSessionToken(token: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(token);
|
||||
const digest = await crypto.subtle.digest('SHA-256', data);
|
||||
return bufferToHex(digest);
|
||||
}
|
||||
|
||||
function bufferToHex(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let hex = '';
|
||||
for (const byte of bytes) {
|
||||
hex += byte.toString(16).padStart(2, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
const SESSION_USER_QUERY = `SELECT users.id, users.email, users.name, users.image_url, users.role, sessions.expires_at
|
||||
FROM sessions JOIN users ON users.id = sessions.user_id
|
||||
WHERE sessions.session_token_hash = ?
|
||||
LIMIT 1`;
|
||||
|
||||
type SessionUserRow = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
image_url: string | null;
|
||||
role: 'user' | 'admin';
|
||||
expires_at: string;
|
||||
};
|
||||
|
||||
export async function getCurrentUser(request: Request, env: Env): Promise<AuthUser | null> {
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookies = parseCookieHeader(cookieHeader);
|
||||
const sessionToken = cookies[AUTH_SESSION_COOKIE_NAME];
|
||||
if (!sessionToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenHash = await hashSessionToken(sessionToken);
|
||||
|
||||
const row = await env.DB.prepare(SESSION_USER_QUERY).bind(tokenHash).first<SessionUserRow>();
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiresAt = Date.parse(row.expires_at);
|
||||
if (Number.isNaN(expiresAt) || expiresAt <= Date.now()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
imageUrl: row.image_url,
|
||||
role: row.role,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireUser(request: Request, env: Env): Promise<AuthUser> {
|
||||
const user = await getCurrentUser(request, env);
|
||||
if (!user) {
|
||||
throw new AuthError(401, 'Authentication required');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireAdmin(request: Request, env: Env): Promise<AuthUser> {
|
||||
const user = await requireUser(request, env);
|
||||
if (user.role !== 'admin') {
|
||||
throw new AuthError(403, 'Admin access required');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user