~bigbes/lethe

c01a501a4fb0fe47f94c8f36c9e18f73d9571619 — Eugene Blikh a month ago 934cfa2
web: PKCE machinery + Authorization-header attachment in apiFetch
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,
  })
}