diff --git a/tests/session.test.ts b/tests/session.test.ts new file mode 100644 index 0000000..eecdb9b --- /dev/null +++ b/tests/session.test.ts @@ -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(): Promise { + 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'); + }); +}); diff --git a/worker/auth.ts b/worker/auth.ts index b5d297d..e9cbc2c 100644 --- a/worker/auth.ts +++ b/worker/auth.ts @@ -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 { + const cookies: Record = {}; + + 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 { + 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 { + 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(); + 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 { + 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 { + const user = await requireUser(request, env); + if (user.role !== 'admin') { + throw new AuthError(403, 'Admin access required'); + } + return user; +}