165 lines
5.1 KiB
TypeScript
165 lines
5.1 KiB
TypeScript
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>
|
|
}
|