mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-10-01 07:23:18 +00:00
Replace Bearer as the documented session path with Cognito hosted UI plus __Host-ap_* cookies so the SPA can call /api with credentials include.
248 lines
7 KiB
TypeScript
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);
|
|
}
|