mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 06:58:53 +00:00
auth-bundle-2 Phase 8: GUI auth surface (OIDC providers + group mappings + sessions + LoginPage IdP buttons + AuthState refactor + logout wiring)
Closes Phase 8 of cowork/auth-bundle-2-prompt.md. Every Bundle 2 endpoint
now has a permission-gated, data-testid-instrumented React surface.
Frontend changes
================
api/client.ts (Category H — AuthState refactor):
* fetchJSON now sends `credentials: 'include'` on every request so the
HttpOnly session cookie + the JS-readable CSRF cookie ride along with
Bearer-mode requests transparently. Mode is determined per call by
what cookies are present, NOT by a state-machine — the same client
works for Bearer-only deploys, session-only deploys, and the mixed
upgrade path described in cowork/auth-bundles-index.md Category H.
* readCSRFCookie() + isStateChangingMethod() helpers auto-attach
`X-CSRF-Token` to POST/PUT/PATCH/DELETE when the CSRF cookie exists.
Bearer-only callers ride through unchanged (no CSRF cookie → no
header → backend's CSRF middleware skips).
* AuthInfoResponse extended with optional `oidc_providers?:
AuthInfoOIDCProvider[]` matching the Phase 6 server extension.
* New API helpers (1:1 with Phase 5 / 7.5 endpoints):
- listOIDCProviders / createOIDCProvider / updateOIDCProvider /
deleteOIDCProvider / refreshOIDCProvider
- listGroupMappings / addGroupMapping / removeGroupMapping
- listSessions(actorID?, actorType?) / revokeSession / logout
- breakglassLogin / breakglassSetPassword / breakglassUnlock /
breakglassRemove
Permission gates fire server-side; the GUI predicates are UX only.
pages/auth/OIDCProvidersPage.tsx (NEW):
* Lists configured OIDC providers, gated on `auth.oidc.list`.
* Empty state + error state + loading state.
* Embedded Configure-Provider modal with form fields for name,
issuer_url, client_id, client_secret, redirect_uri,
groups_claim_path/format, fetch_userinfo, scopes. Modal hidden
unless caller has `auth.oidc.create`.
* Unsaved-changes confirmation on cancel.
pages/auth/OIDCProviderDetailPage.tsx (NEW):
* Provider config dl + edit/delete/refresh action buttons.
* Edit and refresh require `auth.oidc.edit`. Delete requires
`auth.oidc.delete`.
* Type-confirm-name delete dialog. Surfaces server's 409 Conflict
("ErrOIDCProviderInUse") inline so the operator knows to revoke
the provider's active sessions first.
* Refresh discovery cache button → POST .../refresh → server re-runs
RefreshKeys with the IdP-downgrade-attack defense from Phase 3.
* Group→role mappings link.
pages/auth/GroupMappingsPage.tsx (NEW):
* Per-provider group-claim → role-id mapping CRUD.
* Empty state explains the fail-closed semantics from Phase 3
(no mappings ⇒ no users authenticate via this provider).
* Inline add form (group_name input + role_id select populated from
`authListRoles`); add/remove gated on `auth.oidc.edit`.
pages/auth/SessionsPage.tsx (NEW):
* Default "My sessions" view available to anyone holding
`auth.session.list`.
* "All actors (admin)" toggle exposed only when caller holds
`auth.session.list.all`; renders an actor_id filter input that
threads ?actor_id= through the GET.
* Self-pill marker on the caller's own rows.
* Revoke button is shown when (a) the row is the caller's own session
(handler-side own-bypass) OR (b) caller holds `auth.session.revoke`.
* Confirms via window.confirm; surfaces revocation errors inline.
pages/LoginPage.tsx (MODIFIED):
* Fetches /v1/auth/info on mount; if `oidc_providers[]` is non-empty,
renders one "Sign in with X" button per provider linking to the
provider's `login_url` (the server-side handler in Phase 5 builds
this URL with state + nonce + PKCE verifier sealed in the pre-login
cookie; the GUI never touches those values).
* The API-key form remains as a fallback for Bearer-mode deploys and
the Phase 7.5 break-glass path.
* All interactive elements carry data-testid:
login-oidc-providers / login-oidc-button-{id} / login-api-key-form /
login-api-key-input / login-api-key-submit.
components/AuthProvider.tsx (MODIFIED):
* logout() now also fires POST /auth/logout via the api/client helper
before clearing local state. The endpoint is auth-exempt; the
catch-and-swallow keeps the local logout flow working even if the
cookie is already invalid (idempotent server-side as well).
components/Layout.tsx (MODIFIED):
* Two new nav entries under the Auth section: "OIDC Providers" + "Sessions".
main.tsx (MODIFIED):
* Four new routes:
- /auth/oidc/providers
- /auth/oidc/providers/:id
- /auth/oidc/providers/:id/mappings
- /auth/sessions
Vitest coverage
===============
Five new test files, 28 new test cases. Pattern matches Bundle 1
Phase 10's Vitest scaffold (vi.mock api/client, render with
QueryClient + MemoryRouter, authMe-driven permission shaping,
data-testid selectors).
* OIDCProvidersPage.test.tsx (5 tests): ErrorState w/o auth.oidc.list,
empty state, list + create button render, hide-create-button
without auth.oidc.create, submit-creates-via-API.
* OIDCProviderDetailPage.test.tsx (5 tests): ErrorState w/o list,
full-perms render, hide edit/refresh/delete with only list,
refresh button calls API, delete confirm-button stays disabled
until typed text matches provider name.
* GroupMappingsPage.test.tsx (5 tests): ErrorState w/o list, empty
fail-closed warning, mapping rows render, hide-form without
auth.oidc.edit, submit-add-form-calls-API.
* SessionsPage.test.tsx (6 tests): ErrorState w/o list, own sessions
+ self-pill, hide All-actors toggle without list.all, show
toggle with list.all, hide revoke on other-actor sessions without
auth.session.revoke, click-revoke calls API after window.confirm.
* LoginPage.test.tsx (extended +2 tests): renders OIDC buttons when
/auth/info reports providers; omits the OIDC block when none.
Verification
============
* `npx tsc --noEmit` — 0 errors.
* Vitest run across api/components/hooks/utils/auth/pages = 475 tests,
all green.
* `npm run build` — green (980 KB bundle, no surprises vs Phase 7).
* No backend (Go) changes in this commit; Phase 5-7.5 surfaces
consumed unchanged.
Not in this commit (deferred)
=============================
* "Test login flow" button on the provider detail page (prompt §Phase 8
optional row). Requires a server-side test=true flag on the OIDC
login handler — out of scope for the GUI commit.
* `web/src/__tests__/e2e/` Keycloak-via-testcontainers harness for the
15 comprehensive flow checks. Tracked under Phase 10 of
cowork/auth-bundle-2-prompt.md.
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
listOIDCProviders,
|
||||
createOIDCProvider,
|
||||
type OIDCProvider,
|
||||
type OIDCProviderRequest,
|
||||
} from '../../api/client';
|
||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 2 Phase 8 — OIDCProvidersPage.
|
||||
//
|
||||
// Lists every configured OIDC identity provider in the tenant. Each
|
||||
// row shows id, name, issuer URL, client_id, and a deep-link to the
|
||||
// provider detail page.
|
||||
//
|
||||
// Render-time permission gating:
|
||||
// - Page itself requires auth.oidc.list; non-holders see an
|
||||
// ErrorState directing them to ask an admin.
|
||||
// - "Configure provider" button is HIDDEN unless the caller holds
|
||||
// auth.oidc.create (server-side enforcement is still load-bearing).
|
||||
//
|
||||
// data-testid attributes flag every interactive element so the future
|
||||
// E2E suite can assert behaviour without brittle CSS selectors. Same
|
||||
// pattern as Bundle 1's RolesPage.
|
||||
// =============================================================================
|
||||
|
||||
interface CreateProviderModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function CreateProviderModal({ isOpen, onClose, onSuccess }: CreateProviderModalProps) {
|
||||
const [form, setForm] = useState<OIDCProviderRequest>({
|
||||
name: '',
|
||||
issuer_url: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
redirect_uri: '',
|
||||
groups_claim_path: 'groups',
|
||||
groups_claim_format: 'string-array',
|
||||
fetch_userinfo: false,
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
iat_window_seconds: 300,
|
||||
jwks_cache_ttl_seconds: 3600,
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const update = <K extends keyof OIDCProviderRequest>(k: K, v: OIDCProviderRequest[K]) => {
|
||||
setForm(prev => ({ ...prev, [k]: v }));
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim() || !form.issuer_url.trim() || !form.client_id.trim() || !form.client_secret) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await createOIDCProvider(form);
|
||||
setDirty(false);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (dirty && !window.confirm('Discard unsaved changes?')) return;
|
||||
setDirty(false);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={handleClose}>
|
||||
<div
|
||||
className="bg-surface border border-surface-border rounded p-5 w-full max-w-lg shadow-xl max-h-[90vh] overflow-y-auto"
|
||||
onClick={e => e.stopPropagation()}
|
||||
data-testid="create-oidc-provider-modal"
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-ink mb-4">Configure OIDC provider</h2>
|
||||
{error && (
|
||||
<div
|
||||
className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700"
|
||||
data-testid="create-oidc-provider-error"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Display name *</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={e => update('name', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
|
||||
required
|
||||
data-testid="oidc-provider-name-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Issuer URL *</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.issuer_url}
|
||||
onChange={e => update('issuer_url', e.target.value)}
|
||||
placeholder="https://idp.example.com/realm/main"
|
||||
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
|
||||
required
|
||||
data-testid="oidc-provider-issuer-url-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Client ID *</label>
|
||||
<input
|
||||
value={form.client_id}
|
||||
onChange={e => update('client_id', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
|
||||
required
|
||||
data-testid="oidc-provider-client-id-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Client secret *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.client_secret}
|
||||
onChange={e => update('client_secret', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
|
||||
required
|
||||
data-testid="oidc-provider-client-secret-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Redirect URI *</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.redirect_uri}
|
||||
onChange={e => update('redirect_uri', e.target.value)}
|
||||
placeholder="https://certctl.example.com/auth/oidc/callback"
|
||||
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
|
||||
required
|
||||
data-testid="oidc-provider-redirect-uri-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Groups claim path</label>
|
||||
<input
|
||||
value={form.groups_claim_path}
|
||||
onChange={e => update('groups_claim_path', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
|
||||
data-testid="oidc-provider-groups-claim-path-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-1">Groups claim format</label>
|
||||
<select
|
||||
value={form.groups_claim_format}
|
||||
onChange={e => update('groups_claim_format', e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
|
||||
data-testid="oidc-provider-groups-claim-format-select"
|
||||
>
|
||||
<option value="string-array">string-array</option>
|
||||
<option value="json-path">json-path</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-ink">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.fetch_userinfo || false}
|
||||
onChange={e => update('fetch_userinfo', e.target.checked)}
|
||||
data-testid="oidc-provider-fetch-userinfo-checkbox"
|
||||
/>
|
||||
<span>Fetch groups from userinfo endpoint when ID token claim is empty</span>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink"
|
||||
data-testid="create-oidc-provider-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50"
|
||||
data-testid="create-oidc-provider-submit"
|
||||
>
|
||||
{submitting ? 'Creating…' : 'Create provider'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OIDCProvidersPage() {
|
||||
const { hasPerm } = useAuthMe();
|
||||
const queryClient = useQueryClient();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const canList = hasPerm('auth.oidc.list');
|
||||
const canCreate = hasPerm('auth.oidc.create');
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['oidc-providers'],
|
||||
queryFn: listOIDCProviders,
|
||||
enabled: canList,
|
||||
});
|
||||
|
||||
if (!canList) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<PageHeader title="OIDC providers" subtitle="Identity provider configuration" />
|
||||
<ErrorState error={new Error("You need the auth.oidc.list permission to view OIDC providers. Ask an administrator to grant the permission to your role.")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<PageHeader
|
||||
title="OIDC providers"
|
||||
subtitle="Identity provider configuration"
|
||||
action={
|
||||
canCreate && (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700"
|
||||
data-testid="oidc-providers-create-button"
|
||||
>
|
||||
Configure provider
|
||||
</button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-sm text-ink-muted" data-testid="oidc-providers-loading">
|
||||
Loading providers…
|
||||
</div>
|
||||
)}
|
||||
{error && <ErrorState error={error instanceof Error ? error : new Error(String(error))} />}
|
||||
|
||||
{data && data.providers.length === 0 && (
|
||||
<div className="bg-surface border border-surface-border rounded p-6 text-center" data-testid="oidc-providers-empty">
|
||||
<p className="text-ink-muted text-sm">
|
||||
No OIDC providers configured.{' '}
|
||||
{canCreate ? 'Click "Configure provider" to add one.' : 'Ask an administrator to configure one.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.providers.length > 0 && (
|
||||
<div className="bg-surface border border-surface-border rounded overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-page border-b border-surface-border">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 font-medium text-ink">Name</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-ink">Issuer URL</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-ink">Client ID</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-ink">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.providers.map((p: OIDCProvider) => (
|
||||
<tr key={p.id} className="border-b border-surface-border hover:bg-page" data-testid={`oidc-provider-row-${p.id}`}>
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
to={`/auth/oidc/providers/${encodeURIComponent(p.id)}`}
|
||||
className="text-brand-600 hover:underline"
|
||||
data-testid={`oidc-provider-link-${p.id}`}
|
||||
>
|
||||
{p.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-ink-muted font-mono text-xs">{p.issuer_url}</td>
|
||||
<td className="px-4 py-2 text-ink-muted font-mono text-xs">{p.client_id}</td>
|
||||
<td className="px-4 py-2 text-ink-muted">
|
||||
{p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateProviderModal
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreate(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['oidc-providers'] });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user