A web/src/api/client.test.ts => web/src/api/client.test.ts +135 -0
@@ 0,0 1,135 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+import { apiFetch, apiFetchVoid, AuthError, APIError } from './client'
+import { tokenStore } from '../lib/auth'
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function makeOkResponse(body: unknown = {}, status = 200): Response {
+ return {
+ ok: true,
+ status,
+ headers: { get: () => null },
+ json: () => Promise.resolve(body),
+ } as unknown as Response
+}
+
+function makeErrorResponse(status: number, contentType?: string, body?: unknown): Response {
+ return {
+ ok: false,
+ status,
+ headers: {
+ get: (h: string) => h.toLowerCase() === 'content-type' ? (contentType ?? null) : null,
+ },
+ json: () => Promise.resolve(body),
+ } as unknown as Response
+}
+
+// ── Setup / teardown ─────────────────────────────────────────────────────────
+
+let fetchSpy: ReturnType<typeof vi.fn>
+
+beforeEach(() => {
+ fetchSpy = vi.fn()
+ vi.stubGlobal('fetch', fetchSpy)
+ tokenStore.set(null)
+})
+
+afterEach(() => {
+ tokenStore.set(null)
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+})
+
+// ── apiFetch: Authorization header behavior ──────────────────────────────────
+
+describe('apiFetch Authorization header', () => {
+ it('no stored token → fetch called without Authorization header', async () => {
+ fetchSpy.mockResolvedValue(makeOkResponse({ result: 1 }))
+
+ await apiFetch('/api/v1/test')
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
+ const headers = init.headers as Record<string, string>
+ expect(headers['Authorization']).toBeUndefined()
+ })
+
+ it('stored token → fetch called with Authorization: Bearer <token>', async () => {
+ fetchSpy.mockResolvedValue(makeOkResponse({ result: 1 }))
+ tokenStore.set('abc')
+
+ await apiFetch('/api/v1/test')
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
+ const headers = init.headers as Record<string, string>
+ expect(headers['Authorization']).toBe('Bearer abc')
+ })
+
+ it('caller-supplied Authorization header wins over stored token', async () => {
+ fetchSpy.mockResolvedValue(makeOkResponse({ result: 1 }))
+ tokenStore.set('stored-token')
+
+ await apiFetch('/api/v1/test', {
+ headers: { Authorization: 'Bearer caller-token' },
+ })
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
+ const headers = init.headers as Record<string, string>
+ expect(headers['Authorization']).toBe('Bearer caller-token')
+ })
+
+ it('401 → AuthError thrown (regression)', async () => {
+ fetchSpy.mockResolvedValue(makeErrorResponse(401))
+
+ await expect(apiFetch('/api/v1/test')).rejects.toThrow(AuthError)
+ })
+
+ it('500 → APIError thrown (regression)', async () => {
+ fetchSpy.mockResolvedValue(makeErrorResponse(500))
+
+ await expect(apiFetch('/api/v1/test')).rejects.toThrow(APIError)
+ })
+})
+
+// ── apiFetchVoid: Authorization header behavior ──────────────────────────────
+
+describe('apiFetchVoid Authorization header', () => {
+ it('no stored token → fetch called without Authorization header', async () => {
+ fetchSpy.mockResolvedValue(makeOkResponse(undefined, 204))
+
+ await apiFetchVoid('/api/v1/test')
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
+ const headers = init.headers as Record<string, string>
+ expect(headers['Authorization']).toBeUndefined()
+ })
+
+ it('stored token → fetch called with Authorization: Bearer <token>', async () => {
+ fetchSpy.mockResolvedValue(makeOkResponse(undefined, 204))
+ tokenStore.set('xyz')
+
+ await apiFetchVoid('/api/v1/test')
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
+ const headers = init.headers as Record<string, string>
+ expect(headers['Authorization']).toBe('Bearer xyz')
+ })
+
+ it('caller-supplied Authorization header wins over stored token', async () => {
+ fetchSpy.mockResolvedValue(makeOkResponse(undefined, 204))
+ tokenStore.set('stored-token')
+
+ await apiFetchVoid('/api/v1/test', {
+ headers: { Authorization: 'Bearer caller-token' },
+ })
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
+ const headers = init.headers as Record<string, string>
+ expect(headers['Authorization']).toBe('Bearer caller-token')
+ })
+
+ it('401 → AuthError thrown (regression)', async () => {
+ fetchSpy.mockResolvedValue(makeErrorResponse(401))
+
+ await expect(apiFetchVoid('/api/v1/test')).rejects.toThrow(AuthError)
+ })
+})
M web/src/api/client.ts => web/src/api/client.ts +13 -0
@@ 1,3 1,5 @@
+import { tokenStore } from '../lib/auth'
+
export class AuthError extends Error {
override name = 'AuthError'
constructor(message: string) {
@@ 16,11 18,21 @@ export class APIError extends Error {
}
}
+/**
+ * Build the Authorization header value from the in-memory token store.
+ * Returns undefined when no token is present so the header is omitted (IV5).
+ */
+function authHeader(): { Authorization: string } | Record<string, never> {
+ const token = tokenStore.get()
+ return token !== null ? { Authorization: `Bearer ${token}` } : {}
+}
+
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
+ ...authHeader(),
...init?.headers,
},
})
@@ 54,6 66,7 @@ export async function apiFetchVoid(path: string, init?: RequestInit): Promise<vo
...init,
headers: {
Accept: 'application/json',
+ ...authHeader(),
...init?.headers,
},
})
A web/src/lib/auth.test.ts => web/src/lib/auth.test.ts +194 -0
@@ 0,0 1,194 @@
+import { describe, it, expect, afterEach, vi } from 'vitest'
+import { createHash } from 'node:crypto'
+import {
+ generatePKCEPair,
+ generateState,
+ parseCallbackParams,
+ tokenStore,
+ countCallbackFailures,
+} from './auth'
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+function b64urlNode(buf: Buffer): string {
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
+}
+
+// ── generatePKCEPair ─────────────────────────────────────────────────────────
+
+describe('generatePKCEPair', () => {
+ it('verifier length is 43–128 characters (RFC 7636 §4.1)', async () => {
+ const { verifier } = await generatePKCEPair()
+ expect(verifier.length).toBeGreaterThanOrEqual(43)
+ expect(verifier.length).toBeLessThanOrEqual(128)
+ })
+
+ it('verifier contains only base64url-no-pad chars', async () => {
+ const { verifier } = await generatePKCEPair()
+ expect(verifier).toMatch(/^[A-Za-z0-9\-_]+$/)
+ })
+
+ it('challenge has no padding characters', async () => {
+ const { challenge } = await generatePKCEPair()
+ expect(challenge).not.toContain('=')
+ expect(challenge).not.toContain('+')
+ expect(challenge).not.toContain('/')
+ })
+
+ it('challenge is SHA-256(verifier) in base64url-no-pad', async () => {
+ const { verifier, challenge } = await generatePKCEPair()
+ const expected = b64urlNode(createHash('sha256').update(verifier).digest())
+ expect(challenge).toBe(expected)
+ })
+
+ it('two consecutive calls produce different verifiers', async () => {
+ const a = await generatePKCEPair()
+ const b = await generatePKCEPair()
+ expect(a.verifier).not.toBe(b.verifier)
+ })
+})
+
+// ── generateState ────────────────────────────────────────────────────────────
+
+describe('generateState', () => {
+ it('returns a non-empty string', () => {
+ expect(generateState().length).toBeGreaterThan(0)
+ })
+
+ it('two consecutive calls differ', () => {
+ expect(generateState()).not.toBe(generateState())
+ })
+
+ it('always the same fixed length (22 chars for 16 bytes base64url-no-pad)', () => {
+ const len = generateState().length
+ for (let i = 0; i < 5; i++) {
+ expect(generateState().length).toBe(len)
+ }
+ })
+
+ it('contains only base64url-no-pad chars', () => {
+ expect(generateState()).toMatch(/^[A-Za-z0-9\-_]+$/)
+ })
+})
+
+// ── parseCallbackParams ──────────────────────────────────────────────────────
+
+describe('parseCallbackParams', () => {
+ it('?code=abc&state=xyz → {code, state}', () => {
+ const result = parseCallbackParams('?code=abc&state=xyz')
+ expect(result).toEqual({ code: 'abc', state: 'xyz' })
+ })
+
+ it('?error=access_denied&error_description=user-cancelled → {error, errorDescription}', () => {
+ const result = parseCallbackParams('?error=access_denied&error_description=user-cancelled')
+ expect(result).toEqual({ error: 'access_denied', errorDescription: 'user-cancelled' })
+ })
+
+ it('empty string → {error: missing_params}', () => {
+ const result = parseCallbackParams('')
+ expect(result).toEqual({ error: 'missing_params' })
+ })
+
+ it('?error=access_denied without description → {error} only', () => {
+ const result = parseCallbackParams('?error=access_denied')
+ expect(result).toEqual({ error: 'access_denied' })
+ })
+
+ it('?foo=bar with neither code nor error → {error: missing_params}', () => {
+ const result = parseCallbackParams('?foo=bar')
+ expect(result).toEqual({ error: 'missing_params' })
+ })
+})
+
+// ── tokenStore ───────────────────────────────────────────────────────────────
+
+describe('tokenStore', () => {
+ afterEach(() => {
+ tokenStore.set(null)
+ vi.restoreAllMocks()
+ vi.unstubAllGlobals()
+ })
+
+ it('get() is initially null', () => {
+ expect(tokenStore.get()).toBeNull()
+ })
+
+ it('set("x") → get() === "x"', () => {
+ tokenStore.set('x')
+ expect(tokenStore.get()).toBe('x')
+ })
+
+ it('set(null) → get() === null', () => {
+ tokenStore.set('x')
+ tokenStore.set(null)
+ expect(tokenStore.get()).toBeNull()
+ })
+
+ it('subscribe receives values in order', () => {
+ const received: (string | null)[] = []
+ tokenStore.subscribe((v) => received.push(v))
+ tokenStore.set('x')
+ tokenStore.set(null)
+ expect(received).toEqual(['x', null])
+ })
+
+ it('unsubscribe stops further notifications', () => {
+ const received: (string | null)[] = []
+ const unsub = tokenStore.subscribe((v) => received.push(v))
+ tokenStore.set('a')
+ unsub()
+ tokenStore.set('b')
+ expect(received).toEqual(['a'])
+ })
+
+ it('does NOT touch window.localStorage (IV4)', () => {
+ const setItemSpy = vi.fn()
+ vi.stubGlobal('localStorage', {
+ getItem: vi.fn(),
+ setItem: setItemSpy,
+ removeItem: vi.fn(),
+ clear: vi.fn(),
+ key: vi.fn(),
+ length: 0,
+ })
+
+ tokenStore.set('secret')
+ tokenStore.set(null)
+
+ expect(setItemSpy).not.toHaveBeenCalled()
+ })
+})
+
+// ── countCallbackFailures ────────────────────────────────────────────────────
+
+describe('countCallbackFailures', () => {
+ const FIVE_MIN = 300_000
+ const now = 1_000_000
+
+ it('empty log → {count: 0, blocked: false}', () => {
+ expect(countCallbackFailures(now, [])).toEqual({ count: 0, blocked: false })
+ })
+
+ it('3 failures within 5min → {count: 3, blocked: false} (boundary: >3 blocks, not >=3)', () => {
+ const log = [now - 1000, now - 2000, now - 3000]
+ expect(countCallbackFailures(now, log)).toEqual({ count: 3, blocked: false })
+ })
+
+ it('4 failures within 5min → {count: 4, blocked: true}', () => {
+ const log = [now - 1000, now - 2000, now - 3000, now - 4000]
+ expect(countCallbackFailures(now, log)).toEqual({ count: 4, blocked: true })
+ })
+
+ it('4 failures but oldest is > 5min ago → only recent ones count', () => {
+ // 3 recent + 1 old (> 5min ago)
+ const log = [now - 1000, now - 2000, now - 3000, now - FIVE_MIN - 1]
+ expect(countCallbackFailures(now, log)).toEqual({ count: 3, blocked: false })
+ })
+
+ it('is pure — does not mutate log array', () => {
+ const log = [now - 1000, now - 2000]
+ const original = [...log]
+ countCallbackFailures(now, log)
+ expect(log).toEqual(original)
+ })
+})
A web/src/lib/auth.ts => web/src/lib/auth.ts +135 -0
@@ 0,0 1,135 @@
+// ── 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 }
+}
M web/src/test-setup.ts => web/src/test-setup.ts +12 -0
@@ 1,1 1,13 @@
import '@testing-library/jest-dom'
+import { webcrypto } from 'node:crypto'
+
+// Polyfill crypto.subtle for jsdom environments (AS1).
+// jsdom does not ship its own crypto.subtle implementation; we borrow Node's
+// Web Crypto API which is spec-compliant and available in Node ≥ 19.
+if (typeof globalThis.crypto === 'undefined' || !globalThis.crypto.subtle) {
+ Object.defineProperty(globalThis, 'crypto', {
+ value: webcrypto,
+ writable: false,
+ configurable: true,
+ })
+}