From c01a501a4fb0fe47f94c8f36c9e18f73d9571619 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 26 Apr 2026 17:48:55 +0300 Subject: [PATCH] web: PKCE machinery + Authorization-header attachment in apiFetch --- web/src/api/client.test.ts | 135 ++++++++++++++++++++++++++ web/src/api/client.ts | 13 +++ web/src/lib/auth.test.ts | 194 +++++++++++++++++++++++++++++++++++++ web/src/lib/auth.ts | 135 ++++++++++++++++++++++++++ web/src/test-setup.ts | 12 +++ 5 files changed, 489 insertions(+) create mode 100644 web/src/api/client.test.ts create mode 100644 web/src/lib/auth.test.ts create mode 100644 web/src/lib/auth.ts diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a3d2a750e02e705856509c4b03730ffe2a4bfcc --- /dev/null +++ b/web/src/api/client.test.ts @@ -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 + +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 + expect(headers['Authorization']).toBeUndefined() + }) + + it('stored token → fetch called with Authorization: Bearer ', 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 + 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 + 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 + expect(headers['Authorization']).toBeUndefined() + }) + + it('stored token → fetch called with Authorization: Bearer ', 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 + 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 + 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) + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 1e5cc474260d0276ad4903f0d1bd92f9ad9ec1bf..c70b0a33fa13acbb1402dbac0264140666e35b87 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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 { + const token = tokenStore.get() + return token !== null ? { Authorization: `Bearer ${token}` } : {} +} + export async function apiFetch(path: string, init?: RequestInit): Promise { 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 { + 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) + }) +}) diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7cff3f6ec4543f1d259a785465819f020711e3a --- /dev/null +++ b/web/src/lib/auth.ts @@ -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 } +} diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts index c44951a680db0164bd78df59b06bd366a534a39b..34a8d4e3d06528c27e3613151bad63f1c7aed4cd 100644 --- a/web/src/test-setup.ts +++ b/web/src/test-setup.ts @@ -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, + }) +}