// auth.callback.tsx — handles the OIDC authorization code callback. // // NOTE: This file uses raw `fetch` to POST to the OP token endpoint. // This is the ONE allowed exception to the invariant "no raw fetch outside // client.ts": the OP /token endpoint is a cross-origin request to the issuer // host and is not part of the /api/v1/* surface that apiFetch serves. import React, { useEffect, useRef } from 'react' import { createFileRoute, useNavigate } from '@tanstack/react-router' import { tokenStore, countCallbackFailures } from '../lib/auth' import { useAuth } from '../lib/authContext' import { readConfig } from '../lib/config' // ── Types ───────────────────────────────────────────────────────────────────── interface PendingAuth { verifier: string state: string returnTo: string expiresAt: number } const PENDING_KEY = 'lethe_auth_pending' const FAILURES_KEY = 'lethe_auth_failures' // ── Route ───────────────────────────────────────────────────────────────────── export const Route = createFileRoute('/auth/callback')({ validateSearch: (s: Record) => ({ code: typeof s['code'] === 'string' ? s['code'] : undefined, state: typeof s['state'] === 'string' ? s['state'] : undefined, error: typeof s['error'] === 'string' ? s['error'] : undefined, error_description: typeof s['error_description'] === 'string' ? s['error_description'] : undefined, }), component: CallbackRoute, }) // ── Component ───────────────────────────────────────────────────────────────── function CallbackRoute(): React.JSX.Element { const navigate = useNavigate() const { reportAuthError } = useAuth() const search = Route.useSearch() // Prevent double-execution in React StrictMode double-invoke. const ranRef = useRef(false) // blocked state: once set, render "please reload" card with no retry. const [blocked, setBlocked] = React.useState(false) const [authError, setAuthError] = React.useState(null) useEffect(() => { if (ranRef.current) return ranRef.current = true void (async () => { // ── Helper: record a failure and check the anti-loop guard ────────────── function recordFailureAndCheck(): boolean { const raw = localStorage.getItem(FAILURES_KEY) let log: number[] = [] try { if (raw != null) { log = JSON.parse(raw) as number[] if (!Array.isArray(log)) log = [] } } catch { log = [] } const now = Date.now() // Prune entries outside the 5-minute window before appending so the // array can't grow unbounded across many failed reloads. log = log.filter(ts => ts > now - 300_000) log.push(now) localStorage.setItem(FAILURES_KEY, JSON.stringify(log)) const { blocked: isBlocked } = countCallbackFailures(now, log) return isBlocked } // ── Helper: fail with a message ───────────────────────────────────────── function fail(msg: string): void { const isBlocked = recordFailureAndCheck() reportAuthError(msg) setAuthError(msg) if (isBlocked) { setBlocked(true) } } // ── 0. Check for OP-reported error in the callback URL ────────────────── if (search.error != null) { const desc = search.error_description ?? search.error fail(`Authorization error: ${desc}`) return } // ── 1. Validate that code and state are present ───────────────────────── if (search.code == null || search.state == null) { fail('Missing code or state in callback') return } // ── 2. Read and validate the pending auth entry from localStorage ──────── const raw = localStorage.getItem(PENDING_KEY) if (raw == null) { fail('No pending authorization found (state may have expired)') return } let pending: PendingAuth try { pending = JSON.parse(raw) as PendingAuth } catch { fail('Corrupted pending authorization state') return } // State parameter must match. if (pending.state !== search.state) { fail('State mismatch — possible CSRF') return } // TTL check. if (Date.now() > pending.expiresAt) { fail('Authorization request expired — please try again') return } // Consume the pending entry immediately (single-use). localStorage.removeItem(PENDING_KEY) // ── 3. Read config ────────────────────────────────────────────────────── let cfg: ReturnType try { cfg = readConfig() } catch { fail('Auth config missing during token exchange') return } // ── 4. Exchange code for tokens via POST to OP /token ─────────────────── const origin = window.location.origin const body = new URLSearchParams({ grant_type: 'authorization_code', code: search.code, code_verifier: pending.verifier, redirect_uri: `${origin}/auth/callback`, client_id: cfg.clientId, }) let resp: Response try { resp = await fetch(`${cfg.issuer}/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }) } catch (e) { const msg = e instanceof Error ? e.message : String(e) fail(`Token endpoint unreachable: ${msg}`) return } if (!resp.ok) { let detail = resp.statusText try { const errBody = await resp.json() as Record if (typeof errBody['error_description'] === 'string') { detail = errBody['error_description'] } else if (typeof errBody['error'] === 'string') { detail = errBody['error'] } } catch { // ignore JSON parse failure; use status text } fail(`Token exchange failed (${resp.status}): ${detail}`) return } // ── 5. Parse token response ───────────────────────────────────────────── let tokenResp: Record try { tokenResp = await resp.json() as Record } catch { fail('Token endpoint returned invalid JSON') return } const accessToken = tokenResp['access_token'] if (typeof accessToken !== 'string' || accessToken === '') { fail('Token endpoint did not return an access_token') return } // ── 6. Store token and navigate to returnTo ───────────────────────────── // Clear anti-loop failures log on success. localStorage.removeItem(FAILURES_KEY) tokenStore.set(accessToken) await navigate({ to: pending.returnTo }) })() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // mount-only // ── Render ────────────────────────────────────────────────────────────────── if (blocked) { return (
sign-in blocked
Too many failed sign-in attempts. Please reload the page to try again.
) } if (authError != null) { return (
auth error
{authError}
) } // Default: processing the callback. return (
completing sign-in…
Please wait.
) }