diff --git a/CHANGELOG.md b/CHANGELOG.md index 946fa28..cb16b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,37 @@ shape, and the test-id-suffix collision-prevention when the panel is mounted twice on the same page. +- **OIDC JWKS health panel + Refresh-now button (Audit 2026-05-11 Fix 10 — MED-7 GUI half).** + MED-7's backend endpoint `GET /api/v1/auth/oidc/providers/{id}/jwks-status` + (commit `d85114f`) shipped the per-provider verifier counters on + `dev/auth-bundle-2` but the GUI never called it. The audit doc had + prematurely flipped the row to CLOSED; `authOIDCJWKSStatus` in the + API client was dead code. Operators investigating "why is login + failing for this IdP" couldn't see `last_refresh_at`, + `rejected_jws_count`, or `last_error` from the GUI — they had to + drop to curl. New shared component + `web/src/pages/auth/OIDCJWKSStatusPanel.tsx` queries the endpoint + via TanStack Query (30s `staleTime`, `retry: 0` so a 403 hides the + panel silently for callers without `auth.oidc.list`) and renders + six dt/dd rows: Last refresh (with `(never — cold cache)` sentinel + when the timestamp is empty), Refresh count, Rejected JWS count, + Last error (red treatment when non-empty, `(none)` sentinel + otherwise), RFC 9207 iss param ("supported by IdP" / "not + advertised"), and Current KIDs (`(not exposed — query jwks_uri + directly)` sentinel when the backend declines to expose the list). + A "Refresh now" button invokes the existing + `POST .../refresh` (RefreshKeys path) and invalidates the panel's + query so the freshly-updated counters render without a page + reload. The button is hidden for callers without `auth.oidc.edit` + via the panel's optional `canRefresh` prop. Mounted on + `OIDCProviderDetailPage.tsx` between the read-only field display + and the Actions section. 9 Vitest tests pin: loading state, + happy-path-all-six-rows, 403-hides-panel, refresh-invalidates- + query, refresh-failure-surfaces-inline-without-hiding-panel, + never-refreshed-cold-cache-sentinel, current-kids-empty-not- + exposed-sentinel, last-error-red-treatment, and canRefresh=false- + hides-the-button. + - **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`) diff --git a/web/src/pages/auth/OIDCJWKSStatusPanel.test.tsx b/web/src/pages/auth/OIDCJWKSStatusPanel.test.tsx new file mode 100644 index 0000000..0b56351 --- /dev/null +++ b/web/src/pages/auth/OIDCJWKSStatusPanel.test.tsx @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import OIDCJWKSStatusPanel from './OIDCJWKSStatusPanel'; + +// Audit 2026-05-11 Fix 10 — OIDCJWKSStatusPanel regression coverage. +// Mocks the API client so tests stay hermetic. Pins: loading state, +// happy-path renders all six dt/dd rows, 403 hides panel silently, +// Refresh-now triggers refresh + cache invalidation, never-refreshed +// renders the cold-cache sentinel, current_kids empty renders the +// "not exposed" sentinel. + +vi.mock('../../api/client', () => ({ + authOIDCJWKSStatus: vi.fn(), + refreshOIDCProvider: vi.fn(), +})); + +import * as client from '../../api/client'; + +function renderWithQueryClient(ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return { + queryClient, + ...render( + {ui}, + ), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + cleanup(); +}); + +describe('OIDCJWKSStatusPanel', () => { + it('LoadingState — renders the loading text while the query is in flight', async () => { + // Never-resolving promise so we can observe the loading state. + vi.mocked(client.authOIDCJWKSStatus).mockReturnValue(new Promise(() => {})); + + renderWithQueryClient(); + + expect(screen.getByTestId('oidc-jwks-status-panel')).toBeTruthy(); + expect(screen.getByTestId('oidc-jwks-status-loading')).toBeTruthy(); + }); + + it('HappyPath — renders all six rows from the snapshot with operator-readable values', async () => { + vi.mocked(client.authOIDCJWKSStatus).mockResolvedValue({ + last_refresh_at: '2026-05-11T12:34:56Z', + current_kids: ['kid-2026-04', 'kid-2026-05'], + refresh_count: 7, + last_error: '', + rejected_jws_count: 2, + iss_param_supported: true, + }); + + renderWithQueryClient(); + + await waitFor(() => screen.getByTestId('oidc-jwks-status-fields')); + + expect(screen.getByTestId('oidc-jwks-status-last-refresh').textContent) + .toContain('2026-05-11T12:34:56Z'); + expect(screen.getByTestId('oidc-jwks-status-refresh-count').textContent) + .toBe('7'); + expect(screen.getByTestId('oidc-jwks-status-rejected-jws-count').textContent) + .toBe('2'); + expect(screen.getByTestId('oidc-jwks-status-last-error').textContent) + .toContain('(none)'); + expect(screen.getByTestId('oidc-jwks-status-iss-param').textContent) + .toBe('supported by IdP'); + expect(screen.getByTestId('oidc-jwks-status-current-kids').textContent) + .toContain('kid-2026-04'); + expect(screen.getByTestId('oidc-jwks-status-current-kids').textContent) + .toContain('kid-2026-05'); + }); + + it('403 — hides panel silently when authOIDCJWKSStatus rejects (caller lacks permission)', async () => { + vi.mocked(client.authOIDCJWKSStatus).mockRejectedValue(new Error('HTTP 403: forbidden')); + + const { container } = renderWithQueryClient( + , + ); + + // The query fires on mount; once it errors the panel returns null. + await waitFor(() => { + expect(screen.queryByTestId('oidc-jwks-status-panel')).toBeNull(); + }); + // No DOM artifact left behind — full unmount. + expect(container.querySelector('[data-testid^="oidc-jwks-status-"]')).toBeNull(); + }); + + it('RefreshNow — calls refreshOIDCProvider then invalidates the status query', async () => { + let firstCall = true; + vi.mocked(client.authOIDCJWKSStatus).mockImplementation(async () => { + if (firstCall) { + firstCall = false; + return { + last_refresh_at: '2026-05-11T10:00:00Z', + current_kids: ['kid-pre'], + refresh_count: 1, + rejected_jws_count: 0, + iss_param_supported: true, + }; + } + return { + last_refresh_at: '2026-05-11T10:05:00Z', + current_kids: ['kid-post'], + refresh_count: 2, + rejected_jws_count: 0, + iss_param_supported: true, + }; + }); + vi.mocked(client.refreshOIDCProvider).mockResolvedValue({ refreshed: true }); + + renderWithQueryClient(); + await waitFor(() => screen.getByTestId('oidc-jwks-status-refresh-count')); + expect(screen.getByTestId('oidc-jwks-status-refresh-count').textContent).toBe('1'); + + fireEvent.click(screen.getByTestId('oidc-jwks-refresh-now')); + + // refreshOIDCProvider was called with the right provider ID. + await waitFor(() => { + expect(client.refreshOIDCProvider).toHaveBeenCalledTimes(1); + }); + expect(client.refreshOIDCProvider).toHaveBeenCalledWith('op-okta'); + + // The status query was re-fetched (second authOIDCJWKSStatus call) + // and the panel renders the new refresh_count. + await waitFor(() => { + expect(screen.getByTestId('oidc-jwks-status-refresh-count').textContent).toBe('2'); + }); + expect(client.authOIDCJWKSStatus).toHaveBeenCalledTimes(2); + }); + + it('RefreshNow — surfaces refresh failure inline without hiding the panel', async () => { + vi.mocked(client.authOIDCJWKSStatus).mockResolvedValue({ + last_refresh_at: '2026-05-11T10:00:00Z', + current_kids: ['kid-pre'], + refresh_count: 1, + last_error: '', + rejected_jws_count: 0, + iss_param_supported: true, + }); + vi.mocked(client.refreshOIDCProvider).mockRejectedValue( + new Error('HTTP 502: upstream IdP unreachable'), + ); + + renderWithQueryClient(); + await waitFor(() => screen.getByTestId('oidc-jwks-status-refresh-count')); + + fireEvent.click(screen.getByTestId('oidc-jwks-refresh-now')); + + await waitFor(() => screen.getByTestId('oidc-jwks-refresh-error')); + expect(screen.getByTestId('oidc-jwks-refresh-error').textContent) + .toContain('upstream IdP unreachable'); + // Panel still visible — refresh failure doesn't kill the existing snapshot. + expect(screen.getByTestId('oidc-jwks-status-panel')).toBeTruthy(); + expect(screen.getByTestId('oidc-jwks-status-refresh-count').textContent).toBe('1'); + }); + + it('NeverRefreshed — renders the "cold cache" sentinel when last_refresh_at is empty', async () => { + vi.mocked(client.authOIDCJWKSStatus).mockResolvedValue({ + // Backend returns an empty string for "never refreshed" — the + // panel must render an operator-readable sentinel rather than + // a blank cell that looks like a render bug. + last_refresh_at: '', + current_kids: [], + refresh_count: 0, + rejected_jws_count: 0, + iss_param_supported: false, + }); + + renderWithQueryClient(); + + await waitFor(() => screen.getByTestId('oidc-jwks-status-fields')); + expect(screen.getByTestId('oidc-jwks-status-last-refresh').textContent) + .toContain('(never — cold cache)'); + expect(screen.getByTestId('oidc-jwks-status-iss-param').textContent) + .toBe('not advertised'); + }); + + it('CurrentKIDsEmpty — renders the "(not exposed)" sentinel rather than an empty cell', async () => { + vi.mocked(client.authOIDCJWKSStatus).mockResolvedValue({ + last_refresh_at: '2026-05-11T12:00:00Z', + current_kids: [], + refresh_count: 5, + rejected_jws_count: 0, + iss_param_supported: true, + }); + + renderWithQueryClient(); + await waitFor(() => screen.getByTestId('oidc-jwks-status-current-kids')); + + expect(screen.getByTestId('oidc-jwks-status-current-kids').textContent) + .toContain('not exposed'); + }); + + it('LastError — renders the message with a red treatment when non-empty', async () => { + vi.mocked(client.authOIDCJWKSStatus).mockResolvedValue({ + last_refresh_at: '2026-05-11T12:00:00Z', + current_kids: [], + refresh_count: 3, + last_error: 'discovery fetch failed: i/o timeout', + rejected_jws_count: 0, + iss_param_supported: false, + }); + + renderWithQueryClient(); + await waitFor(() => screen.getByTestId('oidc-jwks-status-last-error')); + + expect(screen.getByTestId('oidc-jwks-status-last-error').textContent) + .toContain('discovery fetch failed: i/o timeout'); + }); + + it('CanRefreshFalse — hides the Refresh-now button for read-only callers', async () => { + vi.mocked(client.authOIDCJWKSStatus).mockResolvedValue({ + last_refresh_at: '2026-05-11T12:00:00Z', + current_kids: ['kid-1'], + refresh_count: 4, + rejected_jws_count: 0, + iss_param_supported: true, + }); + + renderWithQueryClient( + , + ); + await waitFor(() => screen.getByTestId('oidc-jwks-status-fields')); + + // Panel + counters render; button is gone. + expect(screen.getByTestId('oidc-jwks-status-panel')).toBeTruthy(); + expect(screen.queryByTestId('oidc-jwks-refresh-now')).toBeNull(); + }); +}); diff --git a/web/src/pages/auth/OIDCJWKSStatusPanel.tsx b/web/src/pages/auth/OIDCJWKSStatusPanel.tsx new file mode 100644 index 0000000..98be3b3 --- /dev/null +++ b/web/src/pages/auth/OIDCJWKSStatusPanel.tsx @@ -0,0 +1,226 @@ +import { useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { + authOIDCJWKSStatus, + refreshOIDCProvider, + type JWKSStatusSnapshot, +} from '../../api/client'; + +// ============================================================================= +// Audit 2026-05-11 Fix 10 — JWKS health panel (MED-7 GUI half). +// +// MED-7 backend (`GET /api/v1/auth/oidc/providers/{id}/jwks-status`, +// commit d85114f) shipped the per-provider verifier counters +// (last_refresh_at, refresh_count, last_error, rejected_jws_count, +// iss_param_supported, current_kids) on dev/auth-bundle-2 but the +// GUI never called the endpoint. `authOIDCJWKSStatus` in the API +// client was dead code; operators debugging "why is login failing +// for this IdP?" had to drop to curl. The whole point of MED-7 was +// to surface this for in-GUI observability — that gap is what this +// panel closes. +// +// What each row means at a glance (for the operator): +// - Last refresh: when did the server last fetch the JWKS doc? +// A long-ago timestamp + high rejected_jws_count = the IdP +// rotated keys and the cache hasn't caught up. +// - Refresh count: cumulative since process boot. A non-zero +// count post-boot proves the auto-refresh path (MED-6) fired +// at least once. +// - Rejected JWS count: number of ID tokens whose signature +// failed verification. Step-change spikes correlate to IdP +// key rotations. +// - Last error: the most recent JWKS-refresh failure message +// (sanitized — no token content). Empty means the cache is +// healthy. +// - RFC 9207 iss param: whether the IdP advertises the +// authorization_response_iss_parameter_supported field at +// discovery time. Informational only — the operator-side +// verifier still demands it by default; this surfaces whether +// the IdP plays ball. +// - Current KIDs: the key fingerprints currently in the cache. +// Backend may decline to expose these (privacy / opacity); +// the panel renders a clear "(not exposed)" sentinel when +// the list is empty so the operator knows the absence is by +// design, not by failure. +// +// "Refresh now" button calls POST .../refresh (RefreshKeys path) +// which re-fetches discovery + JWKS AND re-runs the IdP downgrade- +// attack defense. After refresh the panel's TanStack Query is +// invalidated so the freshly-updated counters render in the UI +// without a manual page reload. +// +// The panel is permission-gated server-side; when a non-admin +// caller (e.g. a viewer role with only auth.oidc.list) loads the +// detail page, the status endpoint returns 403 and the panel +// quietly hides. That keeps the surface unobtrusive for read-only +// users while still giving admins one-click observability. +// ============================================================================= + +interface Props { + providerID: string; + /** Optional. When false, the Refresh-now button is hidden + * (callers without auth.oidc.edit see the read-only panel). */ + canRefresh?: boolean; +} + +export default function OIDCJWKSStatusPanel({ providerID, canRefresh = true }: Props) { + const qc = useQueryClient(); + const statusQuery = useQuery({ + queryKey: ['auth', 'oidc', 'jwks-status', providerID], + queryFn: () => authOIDCJWKSStatus(providerID), + // 30s freshness — operators rarely poll faster than this. + staleTime: 30_000, + // 403 / 404 / 500 — don't drown the page in retries. The panel + // hides itself on error (see below). + retry: 0, + }); + const [refreshing, setRefreshing] = useState(false); + const [refreshErr, setRefreshErr] = useState(null); + + if (statusQuery.error) { + // The most likely error is HTTP 403 for callers without + // auth.oidc.list, in which case we hide the panel silently. + // 404 (unknown provider id) is also possible if the detail + // page is loaded with a stale URL after a provider was deleted + // in another tab — hiding is acceptable there too. We do NOT + // log to console because this isn't an error worth flagging + // to the user; the page itself surfaces the 403 / 404 via its + // own permission / not-found path. + return null; + } + + async function doRefresh() { + setRefreshing(true); + setRefreshErr(null); + try { + await refreshOIDCProvider(providerID); + // Invalidate the status query so the freshly-updated + // counters (refresh_count++, last_refresh_at=now, possibly + // last_error="") render on the next render pass. We don't + // mutate the cache optimistically because the backend's + // refresh path can fail in interesting ways (discovery + // unreachable, alg-downgrade rejection) and we want the + // real post-refresh state to surface. + await qc.invalidateQueries({ + queryKey: ['auth', 'oidc', 'jwks-status', providerID], + }); + } catch (e) { + setRefreshErr(e instanceof Error ? e.message : String(e)); + } finally { + setRefreshing(false); + } + } + + return ( +
+
+
+
JWKS health
+
+ Per-provider verifier counters. Updates live after Refresh now. +
+
+ {canRefresh && ( + + )} +
+
+ {refreshErr && ( +
+ Refresh failed: {refreshErr} +
+ )} + {statusQuery.isLoading && ( +
+ Loading… +
+ )} + {statusQuery.data && ( +
+
Last refresh
+
+ {statusQuery.data.last_refresh_at ? ( + statusQuery.data.last_refresh_at + ) : ( + (never — cold cache) + )} +
+ +
Refresh count
+
+ {statusQuery.data.refresh_count} +
+ +
Rejected JWS count
+
+ {statusQuery.data.rejected_jws_count} +
+ +
Last error
+
+ {statusQuery.data.last_error ? ( + {statusQuery.data.last_error} + ) : ( + (none) + )} +
+ +
RFC 9207 iss param
+
+ {statusQuery.data.iss_param_supported + ? 'supported by IdP' + : 'not advertised'} +
+ +
Current KIDs
+
+ {(statusQuery.data.current_kids ?? []).length === 0 ? ( + + (not exposed — query jwks_uri directly to inspect) + + ) : ( + statusQuery.data.current_kids.join(', ') + )} +
+
+ )} +
+
+ ); +} diff --git a/web/src/pages/auth/OIDCProviderDetailPage.tsx b/web/src/pages/auth/OIDCProviderDetailPage.tsx index da96d85..413aed3 100644 --- a/web/src/pages/auth/OIDCProviderDetailPage.tsx +++ b/web/src/pages/auth/OIDCProviderDetailPage.tsx @@ -13,6 +13,7 @@ import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; import { validateEmailDomain } from './OIDCProvidersPage'; import OIDCTestConnectionPanel from './OIDCTestConnectionPanel'; +import OIDCJWKSStatusPanel from './OIDCJWKSStatusPanel'; // ============================================================================= // Bundle 2 Phase 8 — OIDCProviderDetailPage. @@ -610,6 +611,19 @@ export default function OIDCProviderDetailPage() { )} + {/* Audit 2026-05-11 Fix 10 — JWKS health panel (MED-7 GUI half). + Reads GET .../jwks-status and renders the per-provider verifier + counters (last_refresh_at, refresh_count, last_error, + rejected_jws_count, iss_param_supported, current_kids) so + operators can debug "why is login failing for this IdP?" + without dropping to curl. "Refresh now" button invokes the + existing RefreshKeys path and invalidates the local query so + the freshly-updated counters render immediately. Panel + self-hides for callers without auth.oidc.list (server returns + 403). The refresh button is hidden for callers without + auth.oidc.edit so non-admins can still observe the cache. */} + +

Actions