kereru-forms/frontend/src/lib/oidc.ts

165 lines
5.1 KiB
TypeScript
Raw Normal View History

2026-10-23 19:36:02 +00:00
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<void> {
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<ArrayBuffer> {
return crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
}
function decodeJwtPayload(token: string): Record<string, unknown> {
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<string, unknown>
}