Merge Fix 10 (MED-7 GUI half): JWKS health panel + Refresh-now button

# Conflicts:
#	CHANGELOG.md
#	web/src/pages/auth/OIDCProviderDetailPage.tsx
This commit is contained in:
shankar0123
2026-05-11 12:59:41 +00:00
4 changed files with 506 additions and 0 deletions
+31
View File
@@ -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`)
@@ -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(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
),
};
}
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(<OIDCJWKSStatusPanel providerID="op-okta" />);
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(<OIDCJWKSStatusPanel providerID="op-okta" />);
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(
<OIDCJWKSStatusPanel providerID="op-okta" />,
);
// 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(<OIDCJWKSStatusPanel providerID="op-okta" />);
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(<OIDCJWKSStatusPanel providerID="op-okta" />);
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(<OIDCJWKSStatusPanel providerID="op-okta" />);
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(<OIDCJWKSStatusPanel providerID="op-okta" />);
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(<OIDCJWKSStatusPanel providerID="op-okta" />);
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(
<OIDCJWKSStatusPanel providerID="op-okta" canRefresh={false} />,
);
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();
});
});
+226
View File
@@ -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<JWKSStatusSnapshot, Error>({
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<string | null>(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 (
<section
className="bg-surface border border-surface-border rounded mt-4"
data-testid="oidc-jwks-status-panel"
>
<header className="px-4 py-3 border-b border-surface-border flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-ink">JWKS health</div>
<div className="text-xs text-ink-muted">
Per-provider verifier counters. Updates live after Refresh now.
</div>
</div>
{canRefresh && (
<button
type="button"
onClick={doRefresh}
disabled={refreshing}
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="oidc-jwks-refresh-now"
title="Force a JWKS + discovery re-fetch; re-runs the IdP alg-downgrade defense"
>
{refreshing ? 'Refreshing…' : 'Refresh now'}
</button>
)}
</header>
<div className="px-4 py-3 text-sm">
{refreshErr && (
<div
className="text-red-700 text-xs mb-2"
data-testid="oidc-jwks-refresh-error"
>
Refresh failed: {refreshErr}
</div>
)}
{statusQuery.isLoading && (
<div className="text-ink-muted text-xs" data-testid="oidc-jwks-status-loading">
Loading
</div>
)}
{statusQuery.data && (
<dl
className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-xs"
data-testid="oidc-jwks-status-fields"
>
<dt className="text-ink-muted">Last refresh</dt>
<dd
className="font-mono text-ink"
data-testid="oidc-jwks-status-last-refresh"
>
{statusQuery.data.last_refresh_at ? (
statusQuery.data.last_refresh_at
) : (
<span className="text-ink-muted">(never cold cache)</span>
)}
</dd>
<dt className="text-ink-muted">Refresh count</dt>
<dd
className="font-mono text-ink"
data-testid="oidc-jwks-status-refresh-count"
>
{statusQuery.data.refresh_count}
</dd>
<dt className="text-ink-muted">Rejected JWS count</dt>
<dd
className="font-mono text-ink"
data-testid="oidc-jwks-status-rejected-jws-count"
>
{statusQuery.data.rejected_jws_count}
</dd>
<dt className="text-ink-muted">Last error</dt>
<dd
className="font-mono text-ink"
data-testid="oidc-jwks-status-last-error"
>
{statusQuery.data.last_error ? (
<span className="text-red-700">{statusQuery.data.last_error}</span>
) : (
<span className="text-ink-muted">(none)</span>
)}
</dd>
<dt className="text-ink-muted">RFC 9207 iss param</dt>
<dd
className="font-mono text-ink"
data-testid="oidc-jwks-status-iss-param"
>
{statusQuery.data.iss_param_supported
? 'supported by IdP'
: 'not advertised'}
</dd>
<dt className="text-ink-muted">Current KIDs</dt>
<dd
className="font-mono text-ink break-all"
data-testid="oidc-jwks-status-current-kids"
>
{(statusQuery.data.current_kids ?? []).length === 0 ? (
<span className="text-ink-muted">
(not exposed query jwks_uri directly to inspect)
</span>
) : (
statusQuery.data.current_kids.join(', ')
)}
</dd>
</dl>
)}
</div>
</section>
);
}
@@ -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() {
)}
</div>
{/* 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. */}
<OIDCJWKSStatusPanel providerID={provider.id} canRefresh={canEdit} />
<div className="bg-surface border border-surface-border rounded p-5 space-y-3">
<h2 className="text-base font-semibold text-ink">Actions</h2>
<div className="flex flex-wrap gap-2">