// 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<string, unknown>) => ({
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<string | null>(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<typeof readConfig>
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<string, unknown>
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<string, unknown>
try {
tokenResp = await resp.json() as Record<string, unknown>
} 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 (
<div
className="body body-pad"
style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
>
<div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
<div className="uppercase-mono" style={{ marginBottom: 8 }}>sign-in blocked</div>
<div className="muted" style={{ marginBottom: 16 }}>
Too many failed sign-in attempts. Please reload the page to try again.
</div>
</div>
</div>
)
}
if (authError != null) {
return (
<div
className="body body-pad"
style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
>
<div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
<div className="uppercase-mono" style={{ marginBottom: 8 }}>auth error</div>
<div className="muted">{authError}</div>
</div>
</div>
)
}
// Default: processing the callback.
return (
<div
className="body body-pad"
style={{ display: 'flex', justifyContent: 'center', paddingTop: 60 }}
>
<div className="card" style={{ padding: '24px 32px', textAlign: 'center' }}>
<div className="uppercase-mono" style={{ marginBottom: 8 }}>completing sign-in…</div>
<div className="muted">Please wait.</div>
</div>
</div>
)
}