import type { OIDCClientConfig } from './types' const PENDING_KEY = 'kereru.oidc.pending' const PENDING_MAX_AGE_MS = 10 * 60 * 1000 interface PendingLogin { state: string nonce: string codeVerifier: string returnTo: string createdAt: number } interface TokenResponse { id_token?: string error?: string error_description?: string } export function safeReturnTo(value: string | null | undefined, fallback = '/manage'): string { if (!value || !value.startsWith('/') || value.startsWith('//')) return fallback return value } export async function beginOidcLogin( config: OIDCClientConfig, returnTo: string, ): Promise { const state = randomBase64Url(32) const nonce = randomBase64Url(32) const codeVerifier = randomBase64Url(64) const codeChallenge = base64Url(await sha256(codeVerifier)) const pending: PendingLogin = { state, nonce, codeVerifier, returnTo: safeReturnTo(returnTo), createdAt: Date.now(), } sessionStorage.setItem(PENDING_KEY, JSON.stringify(pending)) const url = new URL(config.authorizationEndpoint) url.searchParams.set('client_id', config.clientId) url.searchParams.set('redirect_uri', oidcRedirectUri()) url.searchParams.set('response_type', 'code') url.searchParams.set('scope', config.scopes.join(' ')) url.searchParams.set('state', state) url.searchParams.set('nonce', nonce) url.searchParams.set('code_challenge', codeChallenge) url.searchParams.set('code_challenge_method', 'S256') window.location.assign(url.toString()) } export async function completeOidcLogin( config: OIDCClientConfig, params: URLSearchParams, ): Promise<{ token: string; returnTo: string }> { const pending = readPending() if (params.get('error')) { throw new Error( params.get('error_description') ?? params.get('error') ?? 'The identity provider rejected sign-in', ) } const state = params.get('state') if (!state || state !== pending.state) { throw new Error('The sign-in response could not be verified. Please try again.') } const code = params.get('code') if (!code) { throw new Error('The identity provider did not return an authorization code.') } const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: oidcRedirectUri(), client_id: config.clientId, code_verifier: pending.codeVerifier, }) const response = await fetch(config.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }) const text = await response.text() let tokens: TokenResponse = {} try { tokens = JSON.parse(text) as TokenResponse } catch { // Keep the generic error below. } if (!response.ok) { throw new Error( tokens.error_description ?? tokens.error ?? `Token exchange failed (${response.status})`, ) } if (!tokens.id_token) { throw new Error('The identity provider did not return an ID token.') } const claims = decodeJwtPayload(tokens.id_token) if (claims.nonce !== pending.nonce) { throw new Error('The sign-in response nonce did not match. Please try again.') } sessionStorage.removeItem(PENDING_KEY) return { token: tokens.id_token, returnTo: pending.returnTo } } export function oidcRedirectUri(): string { return `${window.location.origin}/auth/callback` } function readPending(): PendingLogin { const raw = sessionStorage.getItem(PENDING_KEY) if (!raw) { throw new Error('This sign-in session has expired. Please start again.') } let pending: PendingLogin try { pending = JSON.parse(raw) as PendingLogin } catch { sessionStorage.removeItem(PENDING_KEY) throw new Error('This sign-in session is invalid. Please start again.') } if ( typeof pending.state !== 'string' || typeof pending.nonce !== 'string' || typeof pending.codeVerifier !== 'string' || typeof pending.returnTo !== 'string' || Date.now() - pending.createdAt > PENDING_MAX_AGE_MS ) { sessionStorage.removeItem(PENDING_KEY) throw new Error('This sign-in session has expired. Please start again.') } return pending } function randomBase64Url(byteLength: number): string { const bytes = new Uint8Array(byteLength) crypto.getRandomValues(bytes) return base64Url(bytes) } function base64Url(value: ArrayBuffer | Uint8Array): string { const bytes = value instanceof Uint8Array ? value : new Uint8Array(value) let binary = '' for (const byte of bytes) binary += String.fromCharCode(byte) return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } async function sha256(value: string): Promise { return crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)) } function decodeJwtPayload(token: string): Record { const part = token.split('.')[1] if (!part) throw new Error('The identity provider returned an invalid ID token.') const normalized = part.replace(/-/g, '+').replace(/_/g, '/') const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') const binary = atob(padded) const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) return JSON.parse(new TextDecoder().decode(bytes)) as Record }