export const COOKIE_ACCESS = "ap_at"; export const COOKIE_ID = "ap_it"; export const COOKIE_REFRESH = "ap_rt"; export const COOKIE_OAUTH = "ap_oauth"; export const COOKIE_SESSION_HINT = "ap_sess"; export const ACCESS_MAX_AGE_SEC = 60 * 60; export const REFRESH_MAX_AGE_SEC = 8 * 60 * 60; export const OAUTH_MAX_AGE_SEC = 10 * 60; export type CookieEvent = { cookies?: string[]; headers?: Record; }; export type OauthCookie = { state: string; verifier: string; returnTo: string; }; export type CookieNames = { access: string; id: string; refresh: string; oauth: string; hint: string; }; export type SessionHint = { displayName: string; email: string; }; export function cookieNames(stage: string): CookieNames { const prefix = stage === "local" ? "" : "__Host-"; return { access: `${prefix}${COOKIE_ACCESS}`, id: `${prefix}${COOKIE_ID}`, refresh: `${prefix}${COOKIE_REFRESH}`, oauth: `${prefix}${COOKIE_OAUTH}`, hint: `${prefix}${COOKIE_SESSION_HINT}`, }; } export function parseCookies(event: CookieEvent): Record { const parsed: Record = {}; for (const part of cookieParts(event)) { const eq = part.indexOf("="); if (eq <= 0) continue; const name = part.slice(0, eq).trim(); const value = part.slice(eq + 1).trim(); if (!name) continue; if (Object.prototype.hasOwnProperty.call(parsed, name)) continue; parsed[name] = decodeCookieValue(value); } return parsed; } export function cookieValue(event: CookieEvent, name: string): string | undefined { const value = parseCookies(event)[name]; return value ? value : undefined; } export function sessionCookieValue( event: CookieEvent, kind: keyof CookieNames, stage: string, ): string | undefined { return cookieValue(event, cookieNames(stage)[kind]); } export function serializeCookie( name: string, value: string, options: { maxAge: number; stage: string; path?: string; httpOnly?: boolean }, ): string { const hostPrefixed = name.startsWith("__Host-"); const path = hostPrefixed ? "/" : (options.path ?? "/"); const parts = [ `${name}=${encodeURIComponent(value)}`, `Path=${path}`, "SameSite=Lax", `Max-Age=${options.maxAge}`, ]; if (options.httpOnly !== false) parts.splice(2, 0, "HttpOnly"); if (hostPrefixed || options.stage !== "local") parts.push("Secure"); return parts.join("; "); } export function clearCookie( name: string, stage: string, options: { httpOnly?: boolean } = {}, ): string { return serializeCookie(name, "", { maxAge: 0, stage, path: cookiePath(name), httpOnly: options.httpOnly, }); } export function tokenCookies( tokens: { accessToken: string; idToken: string; refreshToken?: string }, stage: string, ): string[] { const names = cookieNames(stage); const cookies = [ serializeCookie(names.access, tokens.accessToken, { maxAge: ACCESS_MAX_AGE_SEC, stage }), serializeCookie(names.id, tokens.idToken, { maxAge: ACCESS_MAX_AGE_SEC, stage }), ]; if (tokens.refreshToken) { cookies.push( serializeCookie(names.refresh, tokens.refreshToken, { maxAge: REFRESH_MAX_AGE_SEC, stage, path: cookiePath(names.refresh), }), ); } cookies.push( serializeCookie(names.hint, sessionHintValue(tokens.idToken), { maxAge: REFRESH_MAX_AGE_SEC, stage, httpOnly: false, }), ); cookies.push(clearCookie(names.oauth, stage)); return cookies; } export function clearedSessionCookies(stage: string): string[] { const names = cookieNames(stage); const cookies = [ clearCookie(names.access, stage), clearCookie(names.id, stage), clearCookie(names.refresh, stage), clearCookie(names.oauth, stage), clearCookie(names.hint, stage, { httpOnly: false }), ]; if (stage !== "local") { const legacy = cookieNames("local"); cookies.push( serializeCookie(legacy.access, "", { maxAge: 0, stage, path: "/" }), serializeCookie(legacy.id, "", { maxAge: 0, stage, path: "/" }), serializeCookie(legacy.refresh, "", { maxAge: 0, stage, path: "/" }), serializeCookie(legacy.oauth, "", { maxAge: 0, stage, path: "/" }), serializeCookie(legacy.hint, "", { maxAge: 0, stage, path: "/", httpOnly: false }), ); } return cookies; } export function serializeOauthCookie(payload: OauthCookie, stage: string): string { const names = cookieNames(stage); return serializeCookie(names.oauth, encodeOauth(payload), { maxAge: OAUTH_MAX_AGE_SEC, stage }); } export function readOauthCookie(event: CookieEvent, stage: string): OauthCookie | undefined { const raw = sessionCookieValue(event, "oauth", stage); if (!raw) return undefined; try { const parsed = JSON.parse(raw) as Partial; if ( typeof parsed.state !== "string" || !parsed.state || typeof parsed.verifier !== "string" || !parsed.verifier || typeof parsed.returnTo !== "string" ) { return undefined; } return { state: parsed.state, verifier: parsed.verifier, returnTo: parsed.returnTo }; } catch { return undefined; } } export function headerFrom(event: CookieEvent, name: string): string | undefined { const headers = event.headers ?? {}; const needle = name.toLowerCase(); for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === needle) return value; } return undefined; } export function cookieEventFromHeader(cookieHeader: string | undefined): CookieEvent { return { headers: { cookie: cookieHeader } }; } export function sessionHintFromIdToken(token: string): SessionHint | null { const parts = token.split("."); if (parts.length !== 3) return null; try { const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record< string, unknown >; const email = typeof claims.email === "string" && claims.email.includes("@") ? claims.email : ""; if (!email) return null; const displayName = (typeof claims.name === "string" && claims.name) || (typeof claims.given_name === "string" && claims.given_name) || email; return { email, displayName }; } catch { return null; } } function cookiePath(name: string): string { if (name.startsWith("__Host-")) return "/"; return name.endsWith(COOKIE_REFRESH) ? "/api/auth" : "/"; } function sessionHintValue(idToken: string): string { const hint = sessionHintFromIdToken(idToken); return hint ? JSON.stringify(hint) : "1"; } function cookieParts(event: CookieEvent): string[] { const parts: string[] = []; for (const cookie of event.cookies ?? []) { parts.push(cookie); } const header = headerFrom(event, "cookie"); if (header) { for (const part of header.split(";")) { if (part.trim()) parts.push(part); } } return parts; } function decodeCookieValue(value: string): string { try { return decodeURIComponent(value); } catch { return value; } } function encodeOauth(payload: OauthCookie): string { return JSON.stringify(payload); }