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; }; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); 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 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, 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; }