import type { Env } from './env'; export const AUTH_SESSION_COOKIE_NAME = 'heygo_session' as const; export const AUTH_ENABLED_PROVIDERS = ['google', 'github'] as const; export const AUTH_DISABLED_PROVIDERS = ['apple'] as const; export type EnabledAuthProvider = (typeof AUTH_ENABLED_PROVIDERS)[number]; export type DisabledAuthProvider = (typeof AUTH_DISABLED_PROVIDERS)[number]; export type AuthProvider = EnabledAuthProvider | DisabledAuthProvider; export interface AuthUser { id: string; email: string | null; name: string | null; imageUrl: string | null; role: 'user' | 'admin'; } export interface AuthSession { id: string; userId: string; expiresAt: string; } const ENABLED_PROVIDER_SET = new Set(AUTH_ENABLED_PROVIDERS); const ADMIN_EMAIL_SPLIT_PATTERN = /[\s,;]+/; export function isAuthProviderEnabled(provider: string): provider is EnabledAuthProvider { return ENABLED_PROVIDER_SET.has(provider); } export function getSessionCookieAttributes( requestUrl: string | URL, cookieDomain: string, ): string[] { // The request URL is retained so callers can derive a host-specific domain in // the future; the shared Domain attribute now comes from the COOKIE_DOMAIN // env var so each deployment (dev/prod/local) controls its own scope. const url = typeof requestUrl === 'string' ? new URL(requestUrl) : requestUrl; const attributes = ['HttpOnly', 'SameSite=Lax', 'Path=/']; // Only set Secure over HTTPS. Local dev (wrangler dev) runs on HTTP; // a Secure cookie would be silently dropped by the browser. if (url.protocol === 'https:') { attributes.push('Secure'); } if (cookieDomain) { attributes.push(`Domain=${cookieDomain}`); } return attributes; } 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); } export function getConfiguredAdminEmails(env: Pick): Set { const raw = env.ADMIN_EMAILS?.trim(); if (!raw) { return new Set(); } return new Set( raw .split(ADMIN_EMAIL_SPLIT_PATTERN) .map((email) => email.trim().toLowerCase()) .filter(Boolean), ); } export function isConfiguredAdminEmail( email: string | null | undefined, env: Pick, ): boolean { if (!email) { return false; } return getConfiguredAdminEmails(env).has(email.trim().toLowerCase()); } 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: isConfiguredAdminEmail(row.email, env) ? 'admin' : 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; }