From d1d2ae2fb18064ac4da4572f4cca50680120998e Mon Sep 17 00:00:00 2001 From: hzhang Date: Sun, 24 May 2026 01:43:06 +0100 Subject: [PATCH] feat(auth): real OIDC login + remove agents page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the dev-bypass-only AuthProvider with a real backend-mediated OIDC client. Backend ships the OIDC plumbing in Dialectic.Backend@2463129; this SPA drives it. Auth flow: 1. On mount: GET /api/auth/oidc/status (is OIDC configured?) + GET /api/auth/me (who am I — reads the dialectic_session HttpOnly cookie if present). 2. Anon + click 'login' → window.location = /api/auth/oidc/start (backend redirects to IdP authorize URL with PKCE + state). 3. IdP returns to backend /api/auth/oidc/callback → backend redirects here to /oidc/callback#oidc_ticket=. 4. OidcCallbackPage POSTs the ticket to /api/auth/oidc/exchange → backend Set-Cookie's the session JWT → refresh() AuthProvider → navigate to /. 5. Header shows user.name + 'logout' button. Logout → POST /api/auth/logout (cookie cleared by backend) → user state cleared. UI changes: - Header gets a Login button (acid primary style) when anon + OIDC enabled; logged-in pill (name + logout) when authenticated. - 'oidc not configured' message when backend reports /api/auth/oidc/status enabled=false (operator needs to run dialectic-cli config oidc first). - Removed /agents/:id route + AgentActivity page + nav link entirely (per user request; admin endpoint stays on backend for curl). - Removed Link to /agents/* from TopicDetail + Verdict pages. api.ts: - Adds credentials:'same-origin' on every fetch so the session cookie rides along. - Drops the admin: opt + x-dialectic-admin-key header path (no more AgentActivity consumer). - Keeps dev-bypass header support gated on VITE_OIDC_DEV_BYPASS for dev convenience — backend's OIDC_ONLY=true env disables it server- side anyway. types.ts: AgentSummary type removed. Build delta: 178KB → 179KB / gzip 57.12 → 57.70 (oidc-client overhead is just the AuthProvider polling code; no oidc-client-ts lib needed since the backend drives the redirect flow). --- src/App.tsx | 34 ++++++---- src/api.ts | 50 ++++---------- src/auth.tsx | 113 +++++++++++++++++++++++++----- src/pages/AgentActivity.tsx | 132 ------------------------------------ src/pages/OidcCallback.tsx | 85 +++++++++++++++++++++++ src/pages/TopicDetail.tsx | 22 ++---- src/pages/Verdict.tsx | 2 +- src/styles/app.css | 27 ++++++++ src/types.ts | 15 ---- 9 files changed, 248 insertions(+), 232 deletions(-) delete mode 100644 src/pages/AgentActivity.tsx create mode 100644 src/pages/OidcCallback.tsx diff --git a/src/App.tsx b/src/App.tsx index b1025a0..4f56a5f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,12 +3,12 @@ import { useAuth } from './auth'; import { TopicListPage } from './pages/TopicList'; import { TopicDetailPage } from './pages/TopicDetail'; import { VerdictPage } from './pages/Verdict'; -import { AgentActivityPage } from './pages/AgentActivity'; import { NotFoundPage } from './pages/NotFound'; +import { OidcCallbackPage } from './pages/OidcCallback'; import './styles/app.css'; export function App() { - const { user, devBypass } = useAuth(); + const { user, loading, oidcEnabled, login, logout } = useAuth(); return (
@@ -20,29 +20,37 @@ export function App() { topics - agents
- {user ? ( - {user.label} + {loading ? ( + loading… + ) : user ? ( + + + {user.name} + + + + ) : oidcEnabled ? ( + ) : ( - readonly + + anonymous · oidc not configured + )}
- {devBypass && ( -
- DEV BYPASS ACTIVE — auto-attaching x-dev-bypass on - every request; do not run this build in production -
- )}
} /> } /> } /> - } /> + } /> } />
diff --git a/src/api.ts b/src/api.ts index 6c66558..e741fc1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,22 +1,20 @@ // Thin fetch wrapper for the Dialectic backend. // -// Auth: this SPA targets human operators / observers. v1 auth model -// is dev-bypass only — `x-dev-bypass: ` is auto-attached when -// VITE_OIDC_DEV_BYPASS is set at build time. Real OIDC + Keycloak -// redirect ships as v2; dev-bypass covers the entire MVP scope. +// Auth model: +// - Anonymous: works for public topics (backend's optional-auth path). +// - OIDC: backend sets dialectic_session HttpOnly cookie on +// /api/auth/oidc/exchange; we just pass credentials:'same-origin' +// on every call so the cookie rides along. +// - Dev bypass: still supported in dev mode when VITE_OIDC_DEV_BYPASS +// is set at build time AND backend's OIDC_ONLY env is not 'true'. +// The header is auto-attached. Prod images should NOT set this env. // // Backend base: configurable via VITE_DIALECTIC_API_BASE (default // '/api', which works both behind the vite dev proxy and behind nginx // in prod). -// -// Admin endpoints (under /api/admin/*) need a separate header -// `x-dialectic-admin-key`. The frontend reads it from -// localStorage['dialectic-admin-key'] (set via a one-liner in the -// AgentActivity page) so it never gets baked into the bundle. import type { Argument, - AgentSummary, Topic, TopicDetail, TopicStatus, @@ -36,14 +34,10 @@ class HttpError extends Error { async function request( method: string, path: string, - opts: { body?: unknown; admin?: boolean; signal?: AbortSignal } = {}, + opts: { body?: unknown; signal?: AbortSignal } = {}, ): Promise { const headers: Record = {}; if (DEV_BYPASS) headers['x-dev-bypass'] = DEV_BYPASS; - if (opts.admin) { - const adminKey = localStorage.getItem('dialectic-admin-key') ?? ''; - if (adminKey) headers['x-dialectic-admin-key'] = adminKey; - } if (opts.body !== undefined) headers['content-type'] = 'application/json'; const res = await fetch(`${API_BASE}${path}`, { @@ -51,6 +45,9 @@ async function request( headers, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, signal: opts.signal, + // OIDC session cookie rides on every request. Same-origin since + // the SPA + /api/ are reverse-proxied through the same nginx vhost. + credentials: 'same-origin', }); if (!res.ok) { const text = await res.text().catch(() => ''); @@ -94,11 +91,7 @@ export async function getTopic( id: string, signal?: AbortSignal, ): Promise { - const t = await request('GET', `/topics/${encodeURIComponent(id)}`, { signal }); - // camps is nullable pre-allocation; keep null vs [] meaningful — null - // means "not yet allocated" (UI shows '—'), [] would mean "allocated - // but no rows" (impossible by allocator contract, but distinct). - return t; + return request('GET', `/topics/${encodeURIComponent(id)}`, { signal }); } export async function listArguments( @@ -113,7 +106,7 @@ export async function listArguments( return { arguments: r.arguments ?? [], count: r.count ?? 0 }; } -export function getVerdict( +export async function getVerdict( topicId: string, signal?: AbortSignal, ): Promise { @@ -126,19 +119,4 @@ export function getVerdict( }); } -// -------------------------------------------------------------------- -// Admin - -export async function getAgentSummary( - agentId: string, - signal?: AbortSignal, -): Promise { - const r = await request( - 'GET', - `/admin/agents/${encodeURIComponent(agentId)}`, - { admin: true, signal }, - ); - return { ...r, recent_topics: r.recent_topics ?? [] }; -} - export { HttpError }; diff --git a/src/auth.tsx b/src/auth.tsx index bc95c8d..554736b 100644 --- a/src/auth.tsx +++ b/src/auth.tsx @@ -1,31 +1,110 @@ -// AuthProvider — minimal v1: dev-bypass only, no OIDC redirect yet. +// AuthProvider — backend-mediated OIDC login. // -// Real OIDC + Keycloak ships when the backend's OIDC middleware is -// fully wired (Phase 4 in DIALECTIC-V2-DESIGN.md). Until then this -// just surfaces whether dev-bypass is on so the UI can show a banner -// ("dev mode — running as operator") and route admin-gated pages. +// Flow: +// 1. On mount, GET /api/auth/oidc/status to learn if OIDC is configured. +// 2. GET /api/auth/me — if session cookie exists + valid → set user. +// 3. If anon + click Login → window.location = /api/auth/oidc/start +// 4. Backend 302's to IdP → IdP 302's back to /api/auth/oidc/callback +// → backend 302's to /oidc/callback#oidc_ticket=... on this SPA. +// 5. reads the fragment, POST /api/auth/oidc/exchange, +// backend sets the dialectic_session cookie, redirects to /. +// 6. Logout: POST /api/auth/logout (clears cookie) → useAuth refetches. +// +// The session JWT lives only in an HttpOnly cookie; the SPA never sees +// the raw token. /api/auth/me is the canonical "who am I" probe. -import { createContext, useContext, type ReactNode } from 'react'; +import { + createContext, + useCallback, + useContext, + useEffect, + useState, + type ReactNode, +} from 'react'; + +interface User { + id: string; + email: string; + name: string; +} interface AuthCtx { - // Whether dev-bypass token is configured at build time. - devBypass: boolean; - // Display label for the "current user" — in dev-bypass mode this is - // the literal env value (a placeholder until real OIDC). - user: { id: string; label: string } | null; + user: User | null; + loading: boolean; + oidcEnabled: boolean; + login: () => void; + logout: () => Promise; + refresh: () => Promise; } const Ctx = createContext({ - devBypass: false, user: null, + loading: true, + oidcEnabled: false, + login: () => {}, + logout: async () => {}, + refresh: async () => {}, }); +const API_BASE = import.meta.env.VITE_DIALECTIC_API_BASE ?? '/api'; + +async function fetchStatus(): Promise { + try { + const r = await fetch(`${API_BASE}/auth/oidc/status`); + if (!r.ok) return false; + const j = await r.json(); + return Boolean(j.enabled); + } catch { + return false; + } +} + +async function fetchMe(): Promise { + try { + const r = await fetch(`${API_BASE}/auth/me`, { credentials: 'same-origin' }); + if (r.status === 401) return null; + if (!r.ok) return null; + return (await r.json()) as User; + } catch { + return null; + } +} + export function AuthProvider({ children }: { children: ReactNode }) { - const devBypass = Boolean(import.meta.env.VITE_OIDC_DEV_BYPASS); - const user = devBypass - ? { id: 'dev-operator', label: 'dev-operator (bypass)' } - : null; - return {children}; + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [oidcEnabled, setOidcEnabled] = useState(false); + + const refresh = useCallback(async () => { + setLoading(true); + const [enabled, me] = await Promise.all([fetchStatus(), fetchMe()]); + setOidcEnabled(enabled); + setUser(me); + setLoading(false); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const login = useCallback(() => { + // Hard navigation — backend builds the IdP URL + redirects. + window.location.href = `${API_BASE}/auth/oidc/start`; + }, []); + + const logout = useCallback(async () => { + await fetch(`${API_BASE}/auth/logout`, { + method: 'POST', + credentials: 'same-origin', + }); + setUser(null); + }, []); + + return ( + + {children} + + ); } export function useAuth(): AuthCtx { diff --git a/src/pages/AgentActivity.tsx b/src/pages/AgentActivity.tsx deleted file mode 100644 index 18dcf91..0000000 --- a/src/pages/AgentActivity.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; -import { getAgentSummary, HttpError } from '../api'; -import type { AgentSummary } from '../types'; -import { Link } from 'react-router-dom'; -import { fmtRelative, fmtTime } from '../util'; - -export function AgentActivityPage() { - const { id } = useParams<{ id: string }>(); - const [data, setData] = useState(null); - const [err, setErr] = useState(null); - const [needsKey, setNeedsKey] = useState(false); - const [pendingKey, setPendingKey] = useState(''); - - useEffect(() => { - if (!id) return; - const ac = new AbortController(); - getAgentSummary(id, ac.signal) - .then((d) => { - setData(d); - setErr(null); - setNeedsKey(false); - }) - .catch((e) => { - if ((e as Error).name === 'AbortError') return; - if (e instanceof HttpError && (e.status === 401 || e.status === 403)) { - setNeedsKey(true); - setErr(null); - } else { - setErr((e as Error).message); - } - }); - return () => ac.abort(); - }, [id]); - - function saveKey() { - if (!pendingKey.trim()) return; - localStorage.setItem('dialectic-admin-key', pendingKey.trim()); - setPendingKey(''); - window.location.reload(); - } - - if (!id) return
missing agent id
; - - return ( -
- agent -

{id}

- - {needsKey && ( -
-
- this page calls an admin endpoint — paste your - x-dialectic-admin-key (stored in localStorage; never - sent except to /api/admin/*). -
- setPendingKey(e.target.value)} - placeholder="admin key…" - /> -
- - key never leaves this browser -
-
- )} - - {err &&
{err}
} - - {data && ( - <> -
-
-
- {data.key_provisioned ? 'yes' : 'no'} -
-
key provisioned
-
-
-
{data.signups_count}
-
signups
-
-
-
{data.arguments_count}
-
arguments
-
-
-
{data.verdicts_count}
-
verdicts
-
-
- -

recent topics

- {data.recent_topics.length === 0 ? ( -
no recent topics for this agent
- ) : ( - - - - - - - - - - - {data.recent_topics.map((t) => ( - - - - - - - ))} - -
topicrolestatuslast action
- {t.title} - - {t.role} - - {t.status} - - {fmtRelative(t.last_action_at)} -
- )} - - )} -
- ); -} diff --git a/src/pages/OidcCallback.tsx b/src/pages/OidcCallback.tsx new file mode 100644 index 0000000..1156eda --- /dev/null +++ b/src/pages/OidcCallback.tsx @@ -0,0 +1,85 @@ +// /oidc/callback — exchange the one-time ticket for a session cookie. +// +// Backend's /api/auth/oidc/callback 302s here with +// #oidc_ticket= on success, OR +// #oidc_error= on failure (state expired, IdP rejected, etc) +// +// We POST the ticket to /api/auth/oidc/exchange, the backend Set-Cookie's +// the session JWT, then we refresh AuthProvider + navigate to '/'. + +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth'; + +const API_BASE = import.meta.env.VITE_DIALECTIC_API_BASE ?? '/api'; + +export function OidcCallbackPage() { + const navigate = useNavigate(); + const { refresh } = useAuth(); + const [err, setErr] = useState(null); + + useEffect(() => { + // Parse the URL fragment. + const frag = window.location.hash.replace(/^#/, ''); + const params = new URLSearchParams(frag); + const ticket = params.get('oidc_ticket'); + const oidcErr = params.get('oidc_error'); + + if (oidcErr) { + setErr(oidcErr); + return; + } + if (!ticket) { + setErr('no ticket in callback URL'); + return; + } + + let cancelled = false; + (async () => { + try { + const r = await fetch(`${API_BASE}/auth/oidc/exchange`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ ticket }), + }); + if (!r.ok) { + const text = await r.text(); + throw new Error(`HTTP ${r.status}: ${text.slice(0, 200)}`); + } + if (cancelled) return; + // Refresh AuthProvider so the header shows the new user. + await refresh(); + // Strip the fragment + bounce to root. + navigate('/', { replace: true }); + } catch (e) { + if (!cancelled) setErr((e as Error).message); + } + })(); + return () => { + cancelled = true; + }; + }, [navigate, refresh]); + + return ( +
+ {err ? ( + <> +

login failed

+

+ {err} +

+

+ back to topics +

+ + ) : ( + <> + authenticating +

signing in…

+

exchanging one-time ticket for session cookie

+ + )} +
+ ); +} diff --git a/src/pages/TopicDetail.tsx b/src/pages/TopicDetail.tsx index 8a274a4..7f3b682 100644 --- a/src/pages/TopicDetail.tsx +++ b/src/pages/TopicDetail.tsx @@ -111,31 +111,19 @@ export function TopicDetailPage() {
pro
- {proCamp ? ( - {proCamp.agent_id} - ) : ( - - )} + {proCamp ? proCamp.agent_id : }
con
- {conCamp ? ( - {conCamp.agent_id} - ) : ( - - )} + {conCamp ? conCamp.agent_id : }
judge
- {judgeCamp ? ( - {judgeCamp.agent_id} - ) : ( - - )} + {judgeCamp ? judgeCamp.agent_id : }
@@ -157,9 +145,7 @@ export function TopicDetailPage() {
{a.camp.toUpperCase()} - - {a.agent_id} - + {a.agent_id} {fmtRelative(a.posted_at)} diff --git a/src/pages/Verdict.tsx b/src/pages/Verdict.tsx index ffb5f62..fae57a6 100644 --- a/src/pages/Verdict.tsx +++ b/src/pages/Verdict.tsx @@ -59,7 +59,7 @@ export function VerdictPage() {
verdict (structured)
{JSON.stringify(verdict.verdict, null, 2)}
- judge: {verdict.judge_agent_id} + judge: {verdict.judge_agent_id} produced: {fmtTime(verdict.produced_at)} {(verdict.tokens_input + verdict.tokens_output) > 0 && ( diff --git a/src/styles/app.css b/src/styles/app.css index ddff83c..5aa4e1b 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -66,6 +66,33 @@ .app-user { margin-left: auto; } +.auth-btn { + font-size: 0.78rem; + letter-spacing: 0.18em; + text-transform: uppercase; + padding: 4px 12px; + border: 1px solid var(--line); + background: transparent; + color: var(--text); + border-radius: var(--radius); + cursor: pointer; + transition: all 120ms ease; +} +.auth-btn:hover { + border-color: var(--acid); + color: var(--acid); + background: var(--acid-wash); +} +.auth-btn-primary { + background: var(--acid); + color: var(--on-acid); + border-color: var(--acid); +} +.auth-btn-primary:hover { + background: var(--acid-dim); + color: var(--on-acid); + border-color: var(--acid-dim); +} .app-dev-banner { background: rgba(255, 90, 82, 0.1); color: var(--danger); diff --git a/src/types.ts b/src/types.ts index 6355153..4715167 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,18 +68,3 @@ export interface Verdict { produced_at: string; } -// Admin endpoint (next commit) shape — declare now so frontend compiles. -export interface AgentSummary { - agent_id: string; - key_provisioned: boolean; - signups_count: number; - arguments_count: number; - verdicts_count: number; - recent_topics: Array<{ - topic_id: string; - title: string; - status: TopicStatus; - role: Camp | 'volunteer'; - last_action_at: string; - }>; -}