Files
heygo/worker/auth.ts
T
2026-06-20 20:06:10 +10:00

196 lines
5.1 KiB
TypeScript

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<string>(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<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);
}
export function getConfiguredAdminEmails(env: Pick<Env, 'ADMIN_EMAILS'>): Set<string> {
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<Env, 'ADMIN_EMAILS'>,
): 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<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: isConfiguredAdminEmail(row.email, env) ? 'admin' : 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;
}