Merge Fix 09 (MED-5 GUI half): Test Connection panel on OIDC create + edit forms

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
shankar0123
2026-05-11 12:58:48 +00:00
5 changed files with 434 additions and 0 deletions
+23
View File
@@ -39,6 +39,29 @@
synthetic-admin fallback") is fully true. Operator runbook at
`docs/operator/security.md#demo-to-production-cutover-audit-2026-05-11-a-8`.
- **OIDC provider "Test connection" panel (Audit 2026-05-11 Fix 09 — MED-5 GUI half).**
MED-5's backend dry-run endpoint (`POST /api/v1/auth/oidc/test`, gated
`auth.oidc.create`) shipped on `dev/auth-bundle-2` but had no GUI caller —
the `authOIDCTestProvider` function in `web/src/api/client.ts` was dead
code. Operators had to complete the create form blind, save, then click
"Refresh" to discover whether the issuer URL worked; failures left a
broken provider row in the database that had to be deleted before
retrying. New shared component
`web/src/pages/auth/OIDCTestConnectionPanel.tsx` calls the backend
against the live form state and renders a four-row status panel inline:
Discovery fetched, JWKS reachable, supported algs (warns when the IdP
advertises none), and RFC 9207 iss-parameter advertisement (informational
`·` glyph, not ✗, because the spec is SHOULD). Backend per-leg `errors[]`
flow into an inline bullet list. The panel is mounted in the
OIDCProvidersPage create modal AND the OIDCProviderDetailPage edit form —
the edit-form half is load-bearing for verifying IdP rotations (Keycloak
realm rename, Okta tenant move) without committing first. Run button is
disabled until the issuer URL is non-empty (whitespace-trimmed); the
component is read-only — safe to run repeatedly. 8 Vitest tests pin the
glyph-vs-glyph contract (✓/✗/⚠/·), the button-disabled-without-issuer
shape, and the test-id-suffix collision-prevention when the panel is
mounted twice on the same page.
- **Scope-aware actor-role revoke (Audit 2026-05-11 A-4).**
HIGH-10 made it possible to grant the same role to the same actor at
multiple scopes (e.g. `r-operator` on `profile=p-acme` AND `profile=p-globex`)
@@ -12,6 +12,7 @@ import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
import { validateEmailDomain } from './OIDCProvidersPage';
import OIDCTestConnectionPanel from './OIDCTestConnectionPanel';
// =============================================================================
// Bundle 2 Phase 8 — OIDCProviderDetailPage.
@@ -623,6 +624,17 @@ export default function OIDCProviderDetailPage() {
)}
{editing && (
<>
{/* Audit 2026-05-11 Fix 09 — Test Connection panel (MED-5 GUI half).
Lets the operator verify an issuer URL change post-rotation
(e.g. Keycloak realm rename, Okta tenant move) without
committing first. Reads from the live edit state so the
scope of the test matches what Save would persist. */}
<OIDCTestConnectionPanel
issuerURL={editIssuerURL}
clientID={editClientID}
scopes={editScopesInput.split(/\s+/).filter(Boolean)}
testIDSuffix="edit"
/>
<button
onClick={saveEdit}
disabled={submitting}
+11
View File
@@ -10,6 +10,7 @@ import {
import { useAuthMe } from '../../hooks/useAuthMe';
import PageHeader from '../../components/PageHeader';
import ErrorState from '../../components/ErrorState';
import OIDCTestConnectionPanel from './OIDCTestConnectionPanel';
// =============================================================================
// Bundle 2 Phase 8 — OIDCProvidersPage.
@@ -313,6 +314,16 @@ function CreateProviderModal({ isOpen, onClose, onSuccess }: CreateProviderModal
</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"
@@ -0,0 +1,218 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import OIDCTestConnectionPanel from './OIDCTestConnectionPanel';
// Audit 2026-05-11 Fix 09 — OIDCTestConnectionPanel regression coverage.
// Mocks authOIDCTestProvider so the test is hermetic (no real network).
// Pins: button-disabled-without-issuer, happy-path renders all checks
// green, failure-path renders the errors list, iss_param_supported=false
// renders the informational `·` glyph rather than ✗ (since RFC 9207 is
// SHOULD, not MUST).
vi.mock('../../api/client', () => ({
authOIDCTestProvider: vi.fn(),
}));
import * as client from '../../api/client';
beforeEach(() => {
vi.clearAllMocks();
cleanup();
});
describe('OIDCTestConnectionPanel', () => {
it('RunButton — disabled until issuer URL is non-empty', () => {
render(<OIDCTestConnectionPanel issuerURL="" clientID="cid" scopes={['openid']} />);
const btn = screen.getByTestId('oidc-test-connection-run-default') as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('RunButton — enabled when issuer URL is non-empty', () => {
render(
<OIDCTestConnectionPanel
issuerURL="https://idp.example.com"
clientID="cid"
scopes={['openid']}
/>,
);
const btn = screen.getByTestId('oidc-test-connection-run-default') as HTMLButtonElement;
expect(btn.disabled).toBe(false);
});
it('RunButton — also disabled when issuer URL is whitespace-only', () => {
render(<OIDCTestConnectionPanel issuerURL=" " clientID="cid" scopes={[]} />);
const btn = screen.getByTestId('oidc-test-connection-run-default') as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it('HappyPath — renders all four primary checks green when discovery succeeds', async () => {
vi.mocked(client.authOIDCTestProvider).mockResolvedValue({
discovery_succeeded: true,
jwks_reachable: true,
supported_alg_values: ['RS256', 'ES256'],
iss_param_supported: true,
issuer_echo: 'https://idp.example.com',
authorization_url: 'https://idp.example.com/authorize',
token_url: 'https://idp.example.com/token',
jwks_uri: 'https://idp.example.com/jwks',
userinfo_endpoint: 'https://idp.example.com/userinfo',
errors: [],
});
render(
<OIDCTestConnectionPanel
issuerURL="https://idp.example.com"
clientID="certctl"
scopes={['openid', 'profile', 'email']}
/>,
);
fireEvent.click(screen.getByTestId('oidc-test-connection-run-default'));
await waitFor(() => screen.getByTestId('oidc-test-connection-result-default'));
// All four primary checks visible + green.
expect(screen.getByTestId('oidc-test-connection-check-discovery-default').textContent)
.toContain('✓');
expect(screen.getByTestId('oidc-test-connection-check-jwks-default').textContent)
.toContain('✓');
expect(screen.getByTestId('oidc-test-connection-check-algs-default').textContent)
.toContain('✓');
// iss_param SUPPORTED → ✓, not `·`.
expect(screen.getByTestId('oidc-test-connection-check-iss-param-default').textContent)
.toContain('✓');
// Detail rows present.
expect(screen.getByTestId('oidc-test-connection-detail-authz-url-default')).toBeTruthy();
expect(screen.getByTestId('oidc-test-connection-detail-token-url-default')).toBeTruthy();
expect(screen.getByTestId('oidc-test-connection-detail-userinfo-url-default')).toBeTruthy();
// No errors block on happy path.
expect(screen.queryByTestId('oidc-test-connection-errors-list-default')).toBeNull();
// The mocked POST received the staged input.
expect(client.authOIDCTestProvider).toHaveBeenCalledTimes(1);
expect(client.authOIDCTestProvider).toHaveBeenCalledWith({
issuer_url: 'https://idp.example.com',
client_id: 'certctl',
scopes: ['openid', 'profile', 'email'],
});
});
it('FailurePath — renders the errors list when discovery_succeeded is false', async () => {
vi.mocked(client.authOIDCTestProvider).mockResolvedValue({
discovery_succeeded: false,
jwks_reachable: false,
supported_alg_values: [],
iss_param_supported: false,
errors: ['discovery fetch failed: connection refused', 'jwks_uri not advertised'],
});
render(
<OIDCTestConnectionPanel
issuerURL="https://broken.idp.example.com"
clientID="cid"
scopes={['openid']}
/>,
);
fireEvent.click(screen.getByTestId('oidc-test-connection-run-default'));
await waitFor(() => screen.getByTestId('oidc-test-connection-result-default'));
// Discovery + JWKS marked ✗.
expect(screen.getByTestId('oidc-test-connection-check-discovery-default').textContent)
.toContain('✗');
expect(screen.getByTestId('oidc-test-connection-check-jwks-default').textContent)
.toContain('✗');
// Empty alg list → ⚠ warning, not ✗ (the IdP responded but advertised nothing).
expect(screen.getByTestId('oidc-test-connection-check-algs-default').textContent)
.toContain('⚠');
// Errors list rendered with both entries.
const errs = screen.getByTestId('oidc-test-connection-errors-list-default');
expect(errs.textContent).toContain('connection refused');
expect(errs.textContent).toContain('jwks_uri not advertised');
});
it('IssParamFalse — renders the informational `·` glyph when iss_param_supported is false', async () => {
vi.mocked(client.authOIDCTestProvider).mockResolvedValue({
discovery_succeeded: true,
jwks_reachable: true,
supported_alg_values: ['RS256'],
iss_param_supported: false,
issuer_echo: 'https://idp.example.com',
jwks_uri: 'https://idp.example.com/jwks',
errors: [],
});
render(
<OIDCTestConnectionPanel
issuerURL="https://idp.example.com"
clientID="cid"
scopes={['openid']}
/>,
);
fireEvent.click(screen.getByTestId('oidc-test-connection-run-default'));
await waitFor(() => screen.getByTestId('oidc-test-connection-result-default'));
const issRow = screen.getByTestId('oidc-test-connection-check-iss-param-default');
expect(issRow.textContent).toContain('·');
// Must NOT be ✗ — RFC 9207 is SHOULD, not MUST; the panel must
// not visually mark this as a failure.
expect(issRow.textContent).not.toContain('✗');
// Body should explain that this is informational.
expect(issRow.textContent).toContain('informational');
});
it('FetchError — renders a top-level error when authOIDCTestProvider throws', async () => {
vi.mocked(client.authOIDCTestProvider).mockRejectedValue(new Error('network down'));
render(
<OIDCTestConnectionPanel
issuerURL="https://idp.example.com"
clientID="cid"
scopes={['openid']}
/>,
);
fireEvent.click(screen.getByTestId('oidc-test-connection-run-default'));
await waitFor(() => screen.getByTestId('oidc-test-connection-error-default'));
expect(screen.getByTestId('oidc-test-connection-error-default').textContent)
.toContain('network down');
// The success result panel must NOT render alongside an error.
expect(screen.queryByTestId('oidc-test-connection-result-default')).toBeNull();
});
it('TestIDSuffix — same component renders twice on a page without colliding test IDs', async () => {
vi.mocked(client.authOIDCTestProvider).mockResolvedValue({
discovery_succeeded: true,
jwks_reachable: true,
supported_alg_values: ['RS256'],
iss_param_supported: false,
});
render(
<>
<OIDCTestConnectionPanel
issuerURL="https://idp.a.example.com"
clientID="a"
scopes={['openid']}
testIDSuffix="create"
/>
<OIDCTestConnectionPanel
issuerURL="https://idp.b.example.com"
clientID="b"
scopes={['openid']}
testIDSuffix="edit"
/>
</>,
);
// Both panels visible with distinct test IDs — no DOM-id collisions.
expect(screen.getByTestId('oidc-test-connection-panel-create')).toBeTruthy();
expect(screen.getByTestId('oidc-test-connection-panel-edit')).toBeTruthy();
expect(screen.getByTestId('oidc-test-connection-run-create')).toBeTruthy();
expect(screen.getByTestId('oidc-test-connection-run-edit')).toBeTruthy();
});
});
@@ -0,0 +1,170 @@
import { useState } from 'react';
import { authOIDCTestProvider, type TestDiscoveryResult } from '../../api/client';
// =============================================================================
// Audit 2026-05-11 Fix 09 — Test Connection panel (MED-5 GUI half).
//
// MED-5 backend (`POST /api/v1/auth/oidc/test`, commit 00bbef7) shipped
// the dry-run discovery + JWKS-reachability + alg-downgrade-defense probe
// behind `authOIDCTestProvider` in the API client, but no caller existed
// in the UI. Operators had to complete the create form blind, save, then
// click "Refresh" to discover whether the issuer URL worked; failures
// left a broken provider row in the DB that had to be deleted before
// retrying. This panel surfaces the dry-run result before commit.
//
// Embedded above the Submit button on both the OIDCProvidersPage create
// modal and the OIDCProviderDetailPage edit form. The component does
// NOT persist anything — it's a pure read-only probe against the
// configured issuer URL + client ID + scopes. Errors render inline; the
// operator decides whether to proceed with the save.
//
// Why each line matters:
// - discovery_succeeded — did the well-known JSON fetch + parse?
// - jwks_reachable — does the advertised jwks_uri respond?
// - supported_alg_values — early warning if the IdP advertises only
// HS-family algs (RFC 7515 §A.2 / §A.3); the backend rejects these
// at create-time too, but seeing it here saves a round-trip.
// - iss_param_supported — RFC 9207 advertisement check. Informational
// only (the spec is a SHOULD, not a MUST); rendered as `·`, not ✗.
// - userinfo_endpoint — useful to see when fetch_userinfo is
// configured; absence means the IdP doesn't expose the endpoint at
// all (some custom OIDC servers omit it).
// - errors — backend-collected detail; one line per error.
// =============================================================================
interface Props {
issuerURL: string;
clientID: string;
/** Optional. Backend defaults to ['openid'] when empty; the panel
* passes whatever the caller has staged so a test against an IdP
* with custom scope requirements (e.g. Azure AD's `.default`) can
* verify reachability with the real scope set. */
scopes: string[];
/** Optional caller-supplied data-testid suffix so the same panel
* can render twice on the same page (e.g. create vs edit) without
* colliding test IDs. Defaults to `default`. */
testIDSuffix?: string;
}
export default function OIDCTestConnectionPanel({
issuerURL,
clientID,
scopes,
testIDSuffix = 'default',
}: Props) {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState<TestDiscoveryResult | null>(null);
const [err, setErr] = useState<string | null>(null);
const trimmedIssuer = issuerURL.trim();
async function run() {
if (!trimmedIssuer) {
// Defensive — the button is disabled when issuerURL is empty,
// but a programmatic click still goes through the same gate.
setErr('Issuer URL is required before testing.');
return;
}
setBusy(true);
setErr(null);
setResult(null);
try {
const r = await authOIDCTestProvider({
issuer_url: trimmedIssuer,
client_id: clientID,
scopes,
});
setResult(r);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}
const tid = (s: string) => `oidc-test-connection-${s}-${testIDSuffix}`;
return (
<div
className="border border-surface-border rounded p-3 my-3"
data-testid={tid('panel')}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="font-semibold text-sm text-ink">Test connection</div>
<div className="text-xs text-ink-muted">
Dry-run OIDC discovery + JWKS reachability + alg-downgrade defense.
Does NOT persist; safe to run repeatedly before saving.
</div>
</div>
<button
type="button"
onClick={run}
disabled={busy || !trimmedIssuer}
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink disabled:opacity-50 whitespace-nowrap"
data-testid={tid('run')}
>
{busy ? 'Running…' : 'Run test'}
</button>
</div>
{err && (
<div
className="mt-2 text-xs text-red-700"
data-testid={tid('error')}
>
{err}
</div>
)}
{result && (
<ul
className="mt-3 text-xs space-y-1 text-ink"
data-testid={tid('result')}
>
<li data-testid={tid('check-discovery')}>
{result.discovery_succeeded ? '✓' : '✗'} Discovery fetched
{result.issuer_echo ? ` (issuer echoes: ${result.issuer_echo})` : ''}
</li>
<li data-testid={tid('check-jwks')}>
{result.jwks_reachable ? '✓' : '✗'} JWKS reachable
{result.jwks_uri ? ` (${result.jwks_uri})` : ' (no jwks_uri advertised)'}
</li>
<li data-testid={tid('check-algs')}>
{(result.supported_alg_values?.length ?? 0) > 0 ? '✓' : '⚠'} Supported algs:{' '}
<code className="font-mono">
{(result.supported_alg_values ?? []).join(', ') || '(none advertised)'}
</code>
</li>
<li data-testid={tid('check-iss-param')}>
{result.iss_param_supported ? '✓' : '·'} RFC 9207 iss parameter advertised:{' '}
{result.iss_param_supported ? 'yes' : 'no (informational — spec is SHOULD)'}
</li>
{result.authorization_url && (
<li className="text-ink-muted" data-testid={tid('detail-authz-url')}>
· Authorization URL: <code className="font-mono">{result.authorization_url}</code>
</li>
)}
{result.token_url && (
<li className="text-ink-muted" data-testid={tid('detail-token-url')}>
· Token URL: <code className="font-mono">{result.token_url}</code>
</li>
)}
{result.userinfo_endpoint && (
<li className="text-ink-muted" data-testid={tid('detail-userinfo-url')}>
· UserInfo endpoint: <code className="font-mono">{result.userinfo_endpoint}</code>
</li>
)}
{(result.errors ?? []).length > 0 && (
<li className="text-red-700 mt-2" data-testid={tid('errors-list')}>
<strong>Errors ({result.errors!.length}):</strong>
<ul className="ml-4 list-disc">
{result.errors!.map((e, i) => (
<li key={i}>{e}</li>
))}
</ul>
</li>
)}
</ul>
)}
</div>
);
}