seahaven-ap/packages/api/src/auth/cookies.ts
Adam Moussa bbfa6e4a3c
feat(api): switch live auth to host cookie BFF
Replace Bearer as the documented session path with Cognito hosted UI plus __Host-ap_* cookies so the SPA can call /api with credentials include.
2026-09-22 12:39:00 -04:00

248 lines
7 KiB
TypeScript

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<string, string | undefined>;
};
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<string, string> {
const parsed: Record<string, string> = {};
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<OauthCookie>;
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);
}