feat: add session guard

This commit is contained in:
Hermes Agent
2026-06-20 10:58:32 +10:00
parent f7a6b239f6
commit 10158e1f51
2 changed files with 384 additions and 0 deletions
+266
View File
@@ -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
View File
@@ -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;
}