export type AuthUser = { id: string; subject: string; username?: string; displayName: string; clientId: string; tenantId?: string; authorities: string[]; scope: string[]; }; export type AuthSession = { version: 1; user: AuthUser; issuedAt: number; expiresAt: number; accessToken?: string; tokenType?: string; }; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); const DEFAULT_COOKIE_CHUNK_SIZE = 3000; const DEFAULT_COOKIE_MAX_CHUNKS = 20; export async function createSignedJsonValue(value: unknown, secret: string): Promise { const payload = bytesToBase64Url(textEncoder.encode(JSON.stringify(value))); const signature = await signPayload(payload, secret); return `${payload}.${signature}`; } export async function parseSignedJsonValue(value: string | undefined, secret: string): Promise { if (!value) return null; const [payload, signature, extra] = value.split("."); if (!payload || !signature || extra !== undefined) return null; const expected = await signPayload(payload, secret); if (!constantTimeEqual(signature, expected)) return null; try { return JSON.parse(textDecoder.decode(base64UrlToBytes(payload))) as T; } catch { return null; } } export async function createSessionCookieValue(session: AuthSession, secret: string): Promise { return createSignedJsonValue(session, secret); } export function chunkCookieValue(value: string, chunkSize = DEFAULT_COOKIE_CHUNK_SIZE): string[] { if (value.length <= chunkSize) return [value]; const chunks: string[] = []; for (let index = 0; index < value.length; index += chunkSize) { chunks.push(value.slice(index, index + chunkSize)); } return chunks; } export function chunkedCookieName(baseName: string, index: number): string { return index === 0 ? baseName : `${baseName}.${index}`; } export function chunkedCookieNames(baseName: string, maxChunks = DEFAULT_COOKIE_MAX_CHUNKS): string[] { return Array.from({ length: maxChunks }, (_, index) => chunkedCookieName(baseName, index)); } export function readChunkedCookieValue( baseName: string, getValue: (name: string) => string | undefined, maxChunks = DEFAULT_COOKIE_MAX_CHUNKS ): string | undefined { const first = getValue(baseName); if (!first) return undefined; let value = first; for (let index = 1; index < maxChunks; index += 1) { const chunk = getValue(chunkedCookieName(baseName, index)); if (!chunk) break; value += chunk; } return value; } export async function parseSessionCookieValue( value: string | undefined, secret: string, nowSeconds = Math.floor(Date.now() / 1000) ): Promise { const session = await parseSignedJsonValue(value, secret); if (!session || session.version !== 1) return null; if (!session.user?.id || !session.user.clientId || !session.expiresAt) return null; if (session.expiresAt <= nowSeconds) return null; return { ...session, accessToken: typeof session.accessToken === "string" ? session.accessToken : undefined, tokenType: typeof session.tokenType === "string" ? session.tokenType : undefined, user: { ...session.user, authorities: Array.isArray(session.user.authorities) ? session.user.authorities : [], scope: Array.isArray(session.user.scope) ? session.user.scope : [] } }; } async function signPayload(payload: string, secret: string): Promise { const key = await crypto.subtle.importKey( "raw", textEncoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const signature = await crypto.subtle.sign("HMAC", key, textEncoder.encode(payload)); return bytesToBase64Url(new Uint8Array(signature)); } function constantTimeEqual(left: string, right: string): boolean { if (left.length !== right.length) return false; let diff = 0; for (let index = 0; index < left.length; index += 1) { diff |= left.charCodeAt(index) ^ right.charCodeAt(index); } return diff === 0; } function bytesToBase64Url(bytes: Uint8Array): string { let binary = ""; for (let index = 0; index < bytes.length; index += 0x8000) { binary += String.fromCharCode(...bytes.slice(index, index + 0x8000)); } return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function base64UrlToBytes(value: string): Uint8Array { const base64 = value.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(value.length / 4) * 4, "="); const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); return bytes; }