mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
196 lines
5.1 KiB
TypeScript
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;
|
|
}
|