mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:41:31 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user