// ── Base64url helpers ───────────────────────────────────────────────────────── /** * RFC 4648 §5 base64url-no-pad encoding. * Internal helper; exported for tests. */ export function b64url(buf: ArrayBuffer | Uint8Array): string { const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf) let binary = '' for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]) } return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') } // ── PKCE ───────────────────────────────────────────────────────────────────── /** * Generate a PKCE verifier+challenge pair. * Verifier: 43-byte random buffer → 64 base64url chars (satisfies RFC 7636 §4.1). * Challenge: SHA-256(verifier) in base64url-no-pad (AS1 — requires crypto.subtle). */ export async function generatePKCEPair(): Promise<{ verifier: string; challenge: string }> { const raw = crypto.getRandomValues(new Uint8Array(43)) const verifier = b64url(raw) const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) const challenge = b64url(hash) return { verifier, challenge } } // ── State ───────────────────────────────────────────────────────────────────── /** * Generate an opaque CSRF state value. * 16 random bytes → 22 base64url chars (no padding). */ export function generateState(): string { const raw = crypto.getRandomValues(new Uint8Array(16)) return b64url(raw) } // ── Callback params ─────────────────────────────────────────────────────────── type CallbackSuccess = { code: string; state: string } type CallbackError = { error: string; errorDescription?: string } type CallbackParams = CallbackSuccess | CallbackError /** * Parse the query string from an OAuth2/OIDC authorization callback. * * Returns a discriminated union: * - `{ code, state }` on success * - `{ error, errorDescription? }` when the OP reports an error * - `{ error: 'missing_params' }` when neither `code` nor `error` is present */ export function parseCallbackParams(search: string): CallbackParams { const params = new URLSearchParams(search) const code = params.get('code') const state = params.get('state') if (code !== null && state !== null) { return { code, state } } const error = params.get('error') if (error !== null) { const result: CallbackError = { error } const description = params.get('error_description') if (description !== null) { result.errorDescription = description } return result } return { error: 'missing_params' } } // ── TokenStore ──────────────────────────────────────────────────────────────── export interface TokenStore { get(): string | null set(token: string | null): void subscribe(listener: (token: string | null) => void): () => void } function createTokenStore(): TokenStore { let current: string | null = null const listeners: Array<(token: string | null) => void> = [] return { get(): string | null { return current }, set(token: string | null): void { current = token // Notify all registered listeners. Slice to avoid mutation issues during iteration. listeners.slice().forEach((fn) => fn(token)) }, subscribe(listener: (token: string | null) => void): () => void { listeners.push(listener) return () => { const idx = listeners.indexOf(listener) if (idx !== -1) { listeners.splice(idx, 1) } } }, } } /** * Module-level singleton token store. * In-memory only — never touches localStorage (IV4). */ export const tokenStore: TokenStore = createTokenStore() // ── Callback failure counter ────────────────────────────────────────────────── const WINDOW_MS = 300_000 // 5 minutes /** * Pure helper for the callback page's anti-loop guard. * * Counts how many entries in `log` (timestamps in ms) fall within the past * 5 minutes relative to `now`. Returns `blocked: true` when count > 3. * Does not mutate `log`. */ export function countCallbackFailures( now: number, log: number[], ): { count: number; blocked: boolean } { const cutoff = now - WINDOW_MS const count = log.filter((ts) => ts > cutoff).length return { count, blocked: count > 3 } }