// ── 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 }
}