mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
64ad8e525c
Audit 2026-05-11 Fix 09 closure. MED-5's backend dry-run endpoint
(POST /api/v1/auth/oidc/test, gated auth.oidc.create) shipped on
dev/auth-bundle-2 (commit b4b9879) but the GUI never called it —
authOIDCTestProvider in web/src/api/client.ts was dead code.
Operator gap before this fix: complete the create form blind, save,
then click 'Refresh' to discover whether the issuer URL worked.
Discovery failures left a broken provider row in the DB that had
to be deleted before retrying. The MED-5 backend exists to short-
circuit this — surface the dry-run result before commit.
New shared component web/src/pages/auth/OIDCTestConnectionPanel.tsx
calls authOIDCTestProvider against the live form state (issuer URL
+ client ID + parsed scopes) and renders a four-row status panel
inline:
* ✓/✗ Discovery fetched (with issuer-echo from the well-known doc)
* ✓/✗ JWKS reachable (with the discovered jwks_uri)
* ✓/⚠ Supported algs (warning glyph when the IdP advertises none —
distinct from a discovery failure)
* ✓/· RFC 9207 iss-parameter advertised (informational · glyph
rather than ✗ because the spec is SHOULD, not MUST)
Backend per-leg errors[] flow into an inline bullet list. A
top-level rectangle catches network/fetch failures separately.
The Run button is disabled when the issuer URL is empty or
whitespace-only. The component does NOT persist anything — safe
to run repeatedly before the operator clicks Save.
The panel is mounted in two places:
* OIDCProvidersPage create modal (between the form fields and the
Create button) — short-circuits the blind-save footgun for new
provider configs.
* OIDCProviderDetailPage edit form (between the field grid and
the Save button) — load-bearing for verifying IdP rotations
(Keycloak realm rename, Okta tenant move, certctl side-by-side
hostname change) without committing first.
A testIDSuffix prop (default 'create' / 'edit') gives each mount
point a distinct data-testid namespace so both panels can coexist
on a hypothetical page that uses both without DOM-id collisions.
8 Vitest tests in OIDCTestConnectionPanel.test.tsx:
* RunButton — disabled until issuer URL is non-empty
* RunButton — also disabled when issuer URL is whitespace-only
* RunButton — enabled when issuer URL is non-empty
* HappyPath — all four primary checks render green with detail
rows for authorization_url / token_url / userinfo_endpoint
(asserts both the glyph contract AND the mocked POST body shape)
* FailurePath — discovery=false renders ✗ on discovery + ✗ on
JWKS + ⚠ on empty supported algs + error list with backend
per-leg messages
* IssParamFalse — load-bearing UX claim that the iss-parameter
row renders · (informational), not ✗; body must contain the
word 'informational' so operators understand it's not a failure
* FetchError — top-level error rectangle when the POST throws
* TestIDSuffix — same component mounted twice with different
suffixes renders both without DOM-id collision
Verify gate:
* tsc --noEmit — clean
* vitest OIDCTestConnectionPanel.test.tsx — 8/8 pass
* vitest OIDCProvidersPage.test.tsx + OIDCProviderDetailPage.test.tsx
— 38/38 pass (panel-mount in both pages does not regress
existing tests because they don't trigger the test button)
Operator runbook: the four glyph meanings are documented inline on
the panel's subtitle. Audit doc annotation at
cowork/auth-bundles-audit-2026-05-10.md flips MED-5 from
'BACKEND CLOSED' to 'CLOSED' with the GUI-half annotation.
Refs cowork/auth-bundles-fixes-2026-05-11/09-med-oidc-test-connection-button.md.
454 lines
18 KiB
TypeScript
454 lines
18 KiB
TypeScript
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';
|
||
import OIDCTestConnectionPanel from './OIDCTestConnectionPanel';
|
||
|
||
// =============================================================================
|
||
// 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;
|
||
}
|
||
|
||
// Audit 2026-05-11 A-3 — validateEmailDomain mirrors the backend
|
||
// validator at internal/auth/oidc/domain/types.go (CRIT-5 closure).
|
||
// Rejects entries containing `@` / whitespace / `*` / mixed-case, and
|
||
// empties. Returns "" on success; a non-empty string on failure (used
|
||
// directly as the inline error message). The server is still the
|
||
// source of truth; this is the fast-feedback layer.
|
||
export function validateEmailDomain(input: string): string {
|
||
if (!input) return 'Empty entry';
|
||
if (input !== input.trim()) return 'Leading or trailing whitespace';
|
||
if (input !== input.toLowerCase()) return 'Must be all lowercase';
|
||
if (input.includes('@')) return 'Entries are domains, not email addresses — drop the "@" and the local part';
|
||
if (input.includes(' ') || /\s/.test(input)) return 'No whitespace';
|
||
if (input.includes('*')) return 'No wildcards — list each subdomain explicitly';
|
||
if (!input.includes('.')) return 'Must be a fully-qualified domain (e.g. acme.com)';
|
||
return '';
|
||
}
|
||
|
||
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'],
|
||
allowed_email_domains: [],
|
||
iat_window_seconds: 300,
|
||
jwks_cache_ttl_seconds: 3600,
|
||
});
|
||
// Audit 2026-05-11 A-3 — chip-input scratch state for the
|
||
// allowed_email_domains tenant-isolation gate. Operators add domains
|
||
// one at a time; each goes through validateEmailDomain before being
|
||
// appended to form.allowed_email_domains.
|
||
const [emailDomainInput, setEmailDomainInput] = useState('');
|
||
const [emailDomainErr, setEmailDomainErr] = useState<string | null>(null);
|
||
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 addEmailDomain = () => {
|
||
const trimmed = emailDomainInput.trim().toLowerCase();
|
||
setEmailDomainErr(null);
|
||
const v = validateEmailDomain(trimmed);
|
||
if (v !== '') {
|
||
setEmailDomainErr(v);
|
||
return;
|
||
}
|
||
const current = form.allowed_email_domains || [];
|
||
if (current.includes(trimmed)) {
|
||
setEmailDomainErr('Already in the list');
|
||
return;
|
||
}
|
||
update('allowed_email_domains', [...current, trimmed]);
|
||
setEmailDomainInput('');
|
||
};
|
||
|
||
const removeEmailDomain = (d: string) => {
|
||
update(
|
||
'allowed_email_domains',
|
||
(form.allowed_email_domains || []).filter(x => x !== d),
|
||
);
|
||
};
|
||
|
||
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);
|
||
setEmailDomainInput('');
|
||
setEmailDomainErr(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>
|
||
{/* Audit 2026-05-11 A-3 — Allowed email domains chip control.
|
||
When the list is non-empty, only users whose email-domain
|
||
matches one of these entries can complete OIDC login. For
|
||
multi-tenant IdPs (Auth0, Azure AD common endpoint, Google
|
||
Workspace) this is the only thing preventing cross-tenant
|
||
logins; the CRIT-5 backend gate is load-bearing but the GUI
|
||
never exposed it until this fix. */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-ink mb-1">
|
||
Allowed email domains (optional)
|
||
</label>
|
||
<p className="text-xs text-ink-muted mb-2">
|
||
When non-empty, only users whose email domain exactly matches one of these entries
|
||
can log in. Subdomains are NOT auto-accepted — list each one explicitly. Empty list
|
||
means any domain. Case-insensitive exact match.
|
||
</p>
|
||
{(form.allowed_email_domains || []).length > 0 && (
|
||
<div className="flex flex-wrap gap-1 mb-2" data-testid="oidc-create-allowed-email-domains-chips">
|
||
{(form.allowed_email_domains || []).map(d => (
|
||
<span
|
||
key={d}
|
||
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-page border border-surface-border rounded text-ink font-mono"
|
||
data-testid={`oidc-create-allowed-email-domain-chip-${d}`}
|
||
>
|
||
{d}
|
||
<button
|
||
type="button"
|
||
onClick={() => removeEmailDomain(d)}
|
||
className="text-ink-muted hover:text-red-600 leading-none"
|
||
aria-label={`Remove ${d}`}
|
||
data-testid={`oidc-create-allowed-email-domain-chip-remove-${d}`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={emailDomainInput}
|
||
onChange={e => {
|
||
setEmailDomainInput(e.target.value);
|
||
if (emailDomainErr) setEmailDomainErr(null);
|
||
}}
|
||
onKeyDown={e => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
addEmailDomain();
|
||
}
|
||
}}
|
||
placeholder="acme.com"
|
||
className="flex-1 px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
|
||
data-testid="oidc-create-allowed-email-domains-input"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={addEmailDomain}
|
||
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink"
|
||
data-testid="oidc-create-allowed-email-domains-add"
|
||
>
|
||
Add
|
||
</button>
|
||
</div>
|
||
{emailDomainErr && (
|
||
<p
|
||
className="mt-1 text-xs text-red-700"
|
||
data-testid="oidc-create-allowed-email-domains-error"
|
||
>
|
||
{emailDomainErr}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{/* Audit 2026-05-11 Fix 09 — Test Connection panel (MED-5 GUI half).
|
||
Dry-run the issuer URL + JWKS reachability + alg-downgrade defense
|
||
against MED-5's POST /api/v1/auth/oidc/test. Renders inline so the
|
||
operator sees the result before committing. */}
|
||
<OIDCTestConnectionPanel
|
||
issuerURL={form.issuer_url}
|
||
clientID={form.client_id}
|
||
scopes={form.scopes || []}
|
||
testIDSuffix="create"
|
||
/>
|
||
<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>
|
||
);
|
||
}
|