mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 12:21:31 +00:00
feat(gui/oidc): JWKS health panel + Refresh-now button on OIDCProviderDetailPage (MED-7 GUI half)
Audit 2026-05-11 Fix 10 closure. MED-7's backend endpoint
GET /api/v1/auth/oidc/providers/{id}/jwks-status (commit 172b30b)
shipped the per-provider verifier counters on dev/auth-bundle-2
but the GUI never called it — authOIDCJWKSStatus in the API
client was dead code. The audit doc had prematurely flipped the
MED-7 row to CLOSED; this closure makes the claim true.
Operator gap before this fix: operators investigating 'why is
login failing for this IdP?' could not 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 and renders six dt/dd
rows with operator-readable sentinels for each empty case:
* Last refresh — RFC 3339 timestamp; '(never — cold cache)'
sentinel when the IdP has never been hit.
* Refresh count — cumulative since process boot.
* Rejected JWS count — number of ID tokens that failed signature
verification. Step-changes correlate to IdP key rotations.
* Last error — most recent JWKS-refresh failure (sanitized — no
token content). Red treatment when non-empty; '(none)' sentinel
for healthy state.
* RFC 9207 iss param — 'supported by IdP' / 'not advertised'.
Informational only; the operator-side verifier still demands
the param by default.
* Current KIDs — cache contents; '(not exposed — query jwks_uri
directly)' sentinel when the backend declines to expose the
list (the backend may withhold them for opacity).
Refresh-now button:
* Calls POST /api/v1/auth/oidc/providers/{id}/refresh
(RefreshKeys path), then invalidates the panel's query so the
freshly-updated counters render without a page reload.
* Refresh failures surface as an inline red rectangle and do NOT
hide the existing snapshot — partial visibility is better than
no visibility.
* Hidden when the optional canRefresh prop is false. The
OIDCProviderDetailPage mount wires canRefresh to
useAuthMe().hasPerm('auth.oidc.edit') so viewer-class callers
see the read-only panel.
Permission gating:
* The backend endpoint is gated auth.oidc.list. Callers without
the permission get HTTP 403; the panel's TanStack query is
configured with retry: 0 so a 403 doesn't drown the page in
retries, and the panel returns null when the query errors —
hiding silently for callers who can't see the data.
* The Refresh-now button is hidden for callers without
auth.oidc.edit. Read-only callers still see the panel +
counters.
Mount: OIDCProviderDetailPage.tsx between the read-only field
display section and the Actions section. canRefresh wired to
the canEdit boolean already computed at the page level.
9 Vitest tests in OIDCJWKSStatusPanel.test.tsx:
* LoadingState — query in flight, Loading… visible.
* HappyPath — all six dt/dd pairs visible with operator-readable
values; current KIDs joined comma-separated.
* 403 — authOIDCJWKSStatus errors, panel returns null, no DOM
artifacts left behind.
* RefreshNow — calls refreshOIDCProvider('op-okta'), invalidates
the status query, the panel re-fetches and re-renders with the
new refresh_count (mock returns different snapshots on the
two calls).
* RefreshNow surfaces refresh-failure inline without hiding the
panel (preserves the existing snapshot so the operator can
read pre-failure state).
* NeverRefreshed — last_refresh_at='' renders the cold-cache
sentinel rather than a blank cell.
* CurrentKIDsEmpty — empty list renders the 'not exposed'
sentinel rather than a blank cell.
* LastError — non-empty last_error renders with red treatment.
* CanRefreshFalse — panel + counters render; Refresh-now button
is gone.
Verify gate:
* tsc --noEmit — clean
* vitest OIDCJWKSStatusPanel.test.tsx — 9/9 pass
* vitest OIDCProviderDetailPage.test.tsx — 19/19 pass (panel
mount does not break existing tests because the unmocked
authOIDCJWKSStatus call in those tests rejects, the panel
returns null, and the rest of the page renders normally)
Audit doc annotation at cowork/auth-bundles-audit-2026-05-10.md
flips MED-7 from the premature CLOSED claim to a properly-staged
'Backend CLOSED 2026-05-10 + GUI half CLOSED 2026-05-11'
annotation describing the panel + tests.
Refs cowork/auth-bundles-fixes-2026-05-11/10-med-jwks-status-panel.md.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useAuthMe } from '../../hooks/useAuthMe';
|
||||
import PageHeader from '../../components/PageHeader';
|
||||
import ErrorState from '../../components/ErrorState';
|
||||
import { validateEmailDomain } from './OIDCProvidersPage';
|
||||
import OIDCJWKSStatusPanel from './OIDCJWKSStatusPanel';
|
||||
|
||||
// =============================================================================
|
||||
// Bundle 2 Phase 8 — OIDCProviderDetailPage.
|
||||
@@ -609,6 +610,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">
|
||||
|
||||
Reference in New Issue
Block a user