feat(gui/oidc): editable Advanced form on OIDCProviderDetailPage (A-7 / MED-4)

The 2026-05-10 audit tagged MED-4 as DEFERRED to v3 with the rationale
"backend already accepts the five fields." The 2026-05-11 adversarial
review verified the deferral framing was inaccurate — the read-only
`<dl>` rendered scopes / groups_claim_path / groups_claim_format /
iat_window_seconds (and persisted but invisible jwks_cache_ttl_seconds),
which gave operators the impression those fields were editable.
Switching to edit mode revealed no inputs but the saveEdit handler at
OIDCProviderDetailPage.tsx:107-134 silently passed `provider.scopes` /
`provider.groups_claim_path` / etc. through to the PUT body unchanged
from the loaded provider object.

Result: a "lying UX" anti-pattern. The page collected updates to other
fields (display name, issuer URL, client secret, redirect URI,
fetch_userinfo), the PUT succeeded with HTTP 204, and no error fired —
but the displayed Advanced values were whatever the create form
persisted or curl last set. A second operator bumping `iat_window_seconds`
from 60 to 300 had to drop to curl. The "DEFERRED to v3" framing hid
the gap from acquisition reviewers who only inspect the GUI.

Closure (frontend-only — backend already accepts all 5 fields on
`PUT /api/v1/auth/oidc/providers/{id}`):

  OIDCProviderDetailPage.tsx
    - New `<details data-testid="oidc-provider-edit-advanced">` section
      collapsed by default inside the edit form. Most edits don't
      touch these fields, so they shouldn't clutter the primary form.
    - Five new inputs wired through component state:
      * `editScopesInput` — text input rendered as space-separated
        string per OIDC convention (every IdP docs page shows scopes
        that way). Submit splits on whitespace + filters empty strings.
      * `editGroupsClaimPath` — text input with `groups` default.
      * `editGroupsClaimFormat` — select with the actual backend enum
        `string-array` | `json-path` (NOT `string_array` /
        `space_separated` / `comma_separated` as the spec mistakenly
        proposed — those values don't exist in
        `internal/auth/oidc/domain/types.go::GroupsClaimFormat*`).
      * `editIATWindow` — number input with `min=1, max=600` matching
        `MaxIATWindowSeconds=600` from the domain validator.
      * `editJWKSCacheTTL` — number input with `min=60` matching
        `MinJWKSCacheTTLSeconds=60`.
    - `startEdit` pre-populates all five from the live provider so
      operators see current values when expanding the section.
    - `saveEdit` validates client-side mirroring the backend
      `Validate` rules (empty scopes / empty path / invalid format /
      IAT out of (0, 600] / JWKS < 60) → inline error + does NOT
      POST. Server is still source-of-truth; any 400 surfaces via
      the existing error UI.
    - Read-only `<dl>` gained the previously-invisible
      `jwks_cache_ttl_seconds` row so all five values are visible
      without entering edit mode.

  Each input carries a help paragraph linking the operator mental
  model to the backend semantic (e.g. Keycloak's
  `realm_access.roles`, Auth0's namespaced claims; RFC 7519 §4.1.6
  for IAT; MED-6 auto-refresh-on-cache-miss for the JWKS TTL).

Tests (9 new + 5 pre-existing, all passing under vitest):

  A-7 Advanced details section is collapsed by default and visible
    in edit mode — pin <details> has no `open` attribute initially.
  A-7 Advanced fields pre-populate from the live provider — start
    edit with a non-default provider (Keycloak shape: realm_access.roles,
    json-path, IAT=120, JWKS TTL=600); assert each input carries the
    live value.
  A-7 all five Advanced fields round-trip into the PUT body — change
    every field, submit, assert the PUT body carries the parsed shapes
    (whitespace-normalized scopes array, trimmed groups_claim_path,
    enum value, numeric values).
  A-7 IAT window above 600 rejects with inline error and does NOT POST
    — operator types 601, save handler rejects before reaching
    updateOIDCProvider.
  A-7 IAT window <= 0 rejects with inline error.
  A-7 JWKS cache TTL below 60 rejects with inline error.
  A-7 empty scopes input rejects — guards against operator
    accidentally wiping the array via whitespace.
  A-7 empty groups-claim-path rejects.
  A-7 unchanged Advanced fields still round-trip as the existing
    values — pin that a name-only edit still carries the live
    advanced config (no regression to the pass-through behavior;
    operators don't lose their config when editing other fields).

Verify gate green: tsc --noEmit clean; vitest passes all 14 tests
in OIDCProviderDetailPage.test.tsx (5 pre-existing + 9 new A-7
cases).

Spec at cowork/auth-bundles-fixes-2026-05-11/07-high-oidc-provider-advanced-form.md.
Audit doc: MED-4 section in cowork/auth-bundles-audit-2026-05-10.md
appended with the A-7 follow-up closure annotation correcting the
"DEFERRED to v3" framing and explaining the lying-UX pattern;
status table row updated from "CLOSED" (incorrectly tagged on the
pass-through behavior) to "CLOSED 2026-05-11 (A-7)" with the
5-field enumeration. Operator-visible CHANGELOG.md entry under
Security retires the lying-UX caveat.
This commit is contained in:
shankar0123
2026-05-11 11:14:49 +00:00
parent 191384c1d2
commit 9af5dad2b0
3 changed files with 389 additions and 5 deletions
+20
View File
@@ -17,6 +17,26 @@
### Security
- **OIDC provider Advanced fields are now editable in the GUI (Audit 2026-05-11 A-7).**
The MED-4 row had been DEFERRED to v3 with the rationale "backend
already accepts these fields." The verifier hit the GUI and found
that the read-only display claimed the values were editable, but the
edit form had no inputs — the save handler passed `provider.scopes`
/ `provider.groups_claim_path` / `provider.groups_claim_format` /
`provider.iat_window_seconds` / `provider.jwks_cache_ttl_seconds`
unchanged from the loaded object. Operators who wanted to bump the
IAT window or change the groups-claim path had to drop to curl /
MCP and trust the GUI's display matched what they'd set elsewhere.
Lying UX. The OIDCProviderDetailPage edit form now has a collapsible
Advanced section with five inputs (scopes as a space-separated text
field; groups-claim path; groups-claim format select with the
backend's `string-array` / `json-path` enum; IAT window number input
bounded 1600; JWKS cache TTL number input with floor 60). Client-side
validation mirrors the backend `Validate` rules so common operator
mistakes (IAT > 600, JWKS TTL < 60, empty scopes, empty groups-claim-path)
reject inline instead of round-tripping a 400. The read-only `<dl>`
also gained the previously-invisible `jwks_cache_ttl_seconds` row.
- **Pre-login cookie Path widened from `/auth/oidc/` to `/` (Audit MED-14
follow-on).** Required to satisfy the `__Host-` prefix's `Path=/` rule. The
cookie lifetime is unchanged (10 minutes) and only the callback handler
@@ -175,4 +175,178 @@ describe('OIDCProviderDetailPage', () => {
});
expect(confirmBtn.disabled).toBe(false);
});
// =============================================================================
// Audit 2026-05-11 A-7 — Advanced fields are editable (MED-4 closure).
// =============================================================================
async function openEditFormWithEditPerms() {
vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] });
vi.mocked(client.updateOIDCProvider).mockResolvedValue(sampleProvider);
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.edit', scope_type: 'global' },
],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => screen.getByTestId('oidc-provider-edit-button'));
fireEvent.click(screen.getByTestId('oidc-provider-edit-button'));
await waitFor(() => screen.getByTestId('oidc-provider-edit-advanced'));
}
it('A-7 Advanced details section is collapsed by default and visible in edit mode', async () => {
await openEditFormWithEditPerms();
const details = screen.getByTestId('oidc-provider-edit-advanced') as HTMLDetailsElement;
expect(details).toBeTruthy();
// <details> with no `open` attribute = collapsed.
expect(details.open).toBe(false);
});
it('A-7 Advanced fields pre-populate from the live provider', async () => {
vi.mocked(client.listOIDCProviders).mockResolvedValue({
providers: [{
...sampleProvider,
scopes: ['openid', 'profile', 'email', 'groups'],
groups_claim_path: 'realm_access.roles',
groups_claim_format: 'json-path',
iat_window_seconds: 120,
jwks_cache_ttl_seconds: 600,
}],
});
vi.mocked(client.authMe).mockResolvedValue({
actor_id: 'u-admin',
actor_type: 'User',
tenant_id: 't-default',
admin: true,
roles: ['r-admin'],
effective_permissions: [
{ permission: 'auth.oidc.list', scope_type: 'global' },
{ permission: 'auth.oidc.edit', scope_type: 'global' },
],
});
renderRoute(<OIDCProviderDetailPage />);
await waitFor(() => screen.getByTestId('oidc-provider-edit-button'));
fireEvent.click(screen.getByTestId('oidc-provider-edit-button'));
await waitFor(() => screen.getByTestId('oidc-provider-edit-advanced'));
expect((screen.getByTestId('oidc-provider-edit-scopes') as HTMLInputElement).value)
.toBe('openid profile email groups');
expect((screen.getByTestId('oidc-provider-edit-groups-claim-path') as HTMLInputElement).value)
.toBe('realm_access.roles');
expect((screen.getByTestId('oidc-provider-edit-groups-claim-format') as HTMLSelectElement).value)
.toBe('json-path');
expect((screen.getByTestId('oidc-provider-edit-iat-window-seconds') as HTMLInputElement).valueAsNumber)
.toBe(120);
expect((screen.getByTestId('oidc-provider-edit-jwks-cache-ttl-seconds') as HTMLInputElement).valueAsNumber)
.toBe(600);
});
it('A-7 all five Advanced fields round-trip into the PUT body', async () => {
await openEditFormWithEditPerms();
fireEvent.change(screen.getByTestId('oidc-provider-edit-scopes'), {
target: { value: ' openid profile email groups ' },
});
fireEvent.change(screen.getByTestId('oidc-provider-edit-groups-claim-path'), {
target: { value: 'realm_access.roles' },
});
fireEvent.change(screen.getByTestId('oidc-provider-edit-groups-claim-format'), {
target: { value: 'json-path' },
});
fireEvent.change(screen.getByTestId('oidc-provider-edit-iat-window-seconds'), {
target: { value: '120' },
});
fireEvent.change(screen.getByTestId('oidc-provider-edit-jwks-cache-ttl-seconds'), {
target: { value: '600' },
});
fireEvent.click(screen.getByTestId('oidc-provider-save-button'));
await waitFor(() => expect(client.updateOIDCProvider).toHaveBeenCalledTimes(1));
const [, body] = vi.mocked(client.updateOIDCProvider).mock.calls[0];
// Whitespace normalization: collapsed runs, no empty strings.
expect(body.scopes).toEqual(['openid', 'profile', 'email', 'groups']);
expect(body.groups_claim_path).toBe('realm_access.roles');
expect(body.groups_claim_format).toBe('json-path');
expect(body.iat_window_seconds).toBe(120);
expect(body.jwks_cache_ttl_seconds).toBe(600);
});
it('A-7 IAT window above 600 rejects with inline error and does NOT POST', async () => {
await openEditFormWithEditPerms();
fireEvent.change(screen.getByTestId('oidc-provider-edit-iat-window-seconds'), {
target: { value: '601' },
});
fireEvent.click(screen.getByTestId('oidc-provider-save-button'));
await waitFor(() => screen.getByTestId('oidc-provider-detail-error'));
expect(screen.getByTestId('oidc-provider-detail-error').textContent).toContain('IAT window');
expect(client.updateOIDCProvider).not.toHaveBeenCalled();
});
it('A-7 IAT window <= 0 rejects with inline error', async () => {
await openEditFormWithEditPerms();
fireEvent.change(screen.getByTestId('oidc-provider-edit-iat-window-seconds'), {
target: { value: '0' },
});
fireEvent.click(screen.getByTestId('oidc-provider-save-button'));
await waitFor(() => screen.getByTestId('oidc-provider-detail-error'));
expect(client.updateOIDCProvider).not.toHaveBeenCalled();
});
it('A-7 JWKS cache TTL below 60 rejects with inline error', async () => {
await openEditFormWithEditPerms();
fireEvent.change(screen.getByTestId('oidc-provider-edit-jwks-cache-ttl-seconds'), {
target: { value: '30' },
});
fireEvent.click(screen.getByTestId('oidc-provider-save-button'));
await waitFor(() => screen.getByTestId('oidc-provider-detail-error'));
expect(screen.getByTestId('oidc-provider-detail-error').textContent).toContain('JWKS');
expect(client.updateOIDCProvider).not.toHaveBeenCalled();
});
it('A-7 empty scopes input rejects (operator can\'t accidentally wipe the array)', async () => {
await openEditFormWithEditPerms();
fireEvent.change(screen.getByTestId('oidc-provider-edit-scopes'), {
target: { value: ' ' },
});
fireEvent.click(screen.getByTestId('oidc-provider-save-button'));
await waitFor(() => screen.getByTestId('oidc-provider-detail-error'));
expect(screen.getByTestId('oidc-provider-detail-error').textContent).toContain('Scopes');
expect(client.updateOIDCProvider).not.toHaveBeenCalled();
});
it('A-7 empty groups-claim-path rejects', async () => {
await openEditFormWithEditPerms();
fireEvent.change(screen.getByTestId('oidc-provider-edit-groups-claim-path'), {
target: { value: ' ' },
});
fireEvent.click(screen.getByTestId('oidc-provider-save-button'));
await waitFor(() => screen.getByTestId('oidc-provider-detail-error'));
expect(screen.getByTestId('oidc-provider-detail-error').textContent).toContain('Groups claim path');
expect(client.updateOIDCProvider).not.toHaveBeenCalled();
});
it('A-7 unchanged Advanced fields still round-trip as the existing values (no lying field)', async () => {
await openEditFormWithEditPerms();
// Operator only changes Display name; advanced section is untouched.
fireEvent.change(screen.getByTestId('oidc-provider-edit-name'), {
target: { value: 'Okta Rename' },
});
fireEvent.click(screen.getByTestId('oidc-provider-save-button'));
await waitFor(() => expect(client.updateOIDCProvider).toHaveBeenCalledTimes(1));
const [, body] = vi.mocked(client.updateOIDCProvider).mock.calls[0];
// Pre-A-7 these would have been the provider's pass-through; now
// they come from state pre-populated by startEdit. Either way the
// wire value should be the live provider's existing config.
expect(body.scopes).toEqual(sampleProvider.scopes);
expect(body.groups_claim_path).toBe(sampleProvider.groups_claim_path);
expect(body.groups_claim_format).toBe(sampleProvider.groups_claim_format);
expect(body.iat_window_seconds).toBe(sampleProvider.iat_window_seconds);
expect(body.jwks_cache_ttl_seconds).toBe(sampleProvider.jwks_cache_ttl_seconds);
});
});
+195 -5
View File
@@ -49,6 +49,26 @@ export default function OIDCProviderDetailPage() {
const [editClientSecret, setEditClientSecret] = useState('');
const [editRedirectURI, setEditRedirectURI] = useState('');
const [editFetchUserinfo, setEditFetchUserinfo] = useState(false);
// Audit 2026-05-11 A-7 — Advanced edit fields. Pre-fix, the saveEdit
// handler passed these through unchanged from the provider object,
// so the read-only `<dl>` claimed the value was editable but the
// PUT body never carried operator input. The 5 fields the backend
// validator accepts (internal/auth/oidc/domain/types.go::Validate):
// - scopes (string array; min 1 entry; default openid profile email)
// - groups_claim_path (string; default "groups")
// - groups_claim_format (enum: string-array | json-path)
// - iat_window_seconds (int, 1600; default 300)
// - jwks_cache_ttl_seconds (int, ≥60; default 3600)
// Scopes are rendered as a space-separated text input (single-line)
// because that's the operator's mental model — every OIDC IdP docs
// page shows scopes as space-separated. The submit handler splits on
// whitespace + filters empty strings; an empty input renders an
// inline error rather than wiping the array.
const [editScopesInput, setEditScopesInput] = useState('');
const [editGroupsClaimPath, setEditGroupsClaimPath] = useState('');
const [editGroupsClaimFormat, setEditGroupsClaimFormat] = useState('string-array');
const [editIATWindow, setEditIATWindow] = useState<number>(300);
const [editJWKSCacheTTL, setEditJWKSCacheTTL] = useState<number>(3600);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
@@ -94,6 +114,14 @@ export default function OIDCProviderDetailPage() {
setEditClientSecret('');
setEditRedirectURI(provider.redirect_uri);
setEditFetchUserinfo(provider.fetch_userinfo || false);
// Audit 2026-05-11 A-7 — pre-populate the Advanced fields from
// the live provider so the operator sees the current values when
// they expand the section.
setEditScopesInput((provider.scopes ?? []).join(' '));
setEditGroupsClaimPath(provider.groups_claim_path || 'groups');
setEditGroupsClaimFormat(provider.groups_claim_format || 'string-array');
setEditIATWindow(provider.iat_window_seconds || 300);
setEditJWKSCacheTTL(provider.jwks_cache_ttl_seconds || 3600);
setError(null);
setSuccess(null);
setEditing(true);
@@ -109,17 +137,59 @@ export default function OIDCProviderDetailPage() {
setError(null);
setSuccess(null);
try {
// Audit 2026-05-11 A-7 — client-side validation mirrors the
// backend's internal/auth/oidc/domain/types.go::Validate rules.
// Server is still the source of truth (we surface its 400 if
// anything slips past); the client validator is for fast
// feedback so operators don't round-trip just to learn that
// "iat_window_seconds=601" is rejected.
const trimmedPath = editGroupsClaimPath.trim();
if (trimmedPath === '') {
setError('Groups claim path cannot be empty (default: "groups").');
setSubmitting(false);
return;
}
if (editGroupsClaimFormat !== 'string-array' && editGroupsClaimFormat !== 'json-path') {
setError('Groups claim format must be "string-array" or "json-path".');
setSubmitting(false);
return;
}
const scopes = editScopesInput
.trim()
.split(/\s+/)
.filter(s => s.length > 0);
if (scopes.length === 0) {
setError('Scopes cannot be empty. At minimum include "openid".');
setSubmitting(false);
return;
}
if (!Number.isInteger(editIATWindow) || editIATWindow <= 0 || editIATWindow > 600) {
setError('IAT window must be a positive integer ≤ 600 seconds.');
setSubmitting(false);
return;
}
if (!Number.isInteger(editJWKSCacheTTL) || editJWKSCacheTTL < 60) {
setError('JWKS cache TTL must be an integer ≥ 60 seconds.');
setSubmitting(false);
return;
}
const req: Parameters<typeof updateOIDCProvider>[1] = {
name: editName,
issuer_url: editIssuerURL,
client_id: editClientID,
redirect_uri: editRedirectURI,
groups_claim_path: provider.groups_claim_path,
groups_claim_format: provider.groups_claim_format,
// Audit 2026-05-11 A-7 — formerly pass-through from
// provider.*, now wired to the operator-edited state. Lying
// UX retired: the read-only `<dl>` no longer claims a value
// can be changed when the saveEdit handler ignores the
// change.
groups_claim_path: trimmedPath,
groups_claim_format: editGroupsClaimFormat,
fetch_userinfo: editFetchUserinfo,
scopes: provider.scopes,
iat_window_seconds: provider.iat_window_seconds,
jwks_cache_ttl_seconds: provider.jwks_cache_ttl_seconds,
scopes,
iat_window_seconds: editIATWindow,
jwks_cache_ttl_seconds: editJWKSCacheTTL,
};
if (editClientSecret) req.client_secret = editClientSecret;
await updateOIDCProvider(provider.id, req);
@@ -202,6 +272,11 @@ export default function OIDCProviderDetailPage() {
<dd className="col-span-2 font-mono text-xs">{(provider.scopes || []).join(', ')}</dd>
<dt className="text-ink-muted col-span-1">IAT window</dt>
<dd className="col-span-2">{provider.iat_window_seconds}s</dd>
{/* Audit 2026-05-11 A-7 — JWKS cache TTL surfaced in
read-only view too (pre-fix the value was persisted but
invisible). */}
<dt className="text-ink-muted col-span-1">JWKS cache TTL</dt>
<dd className="col-span-2">{provider.jwks_cache_ttl_seconds}s</dd>
</dl>
) : (
<div className="space-y-3">
@@ -262,6 +337,121 @@ export default function OIDCProviderDetailPage() {
/>
<span>Fetch groups from userinfo endpoint when ID token claim is empty</span>
</label>
{/* Audit 2026-05-11 A-7 — Advanced section. Five fields the
read-only <dl> claimed were editable but the saveEdit
handler was passing through unchanged from the loaded
provider object. Each input has an inline help line that
links the operator's mental model to the backend
semantic (`internal/auth/oidc/domain/types.go::Validate`
rules). The section is collapsed by default — most
edits don't touch these fields, so they shouldn't
clutter the primary form. */}
<details
className="border border-surface-border rounded p-3 bg-page"
data-testid="oidc-provider-edit-advanced"
>
<summary className="cursor-pointer text-sm font-medium text-ink select-none">
Advanced (scopes, groups claim, IAT / JWKS TTL)
</summary>
<div className="mt-3 space-y-3">
<div>
<label className="block text-sm font-medium text-ink mb-1">
Scopes (space-separated)
</label>
<input
type="text"
value={editScopesInput}
onChange={e => setEditScopesInput(e.target.value)}
placeholder="openid profile email"
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink font-mono"
data-testid="oidc-provider-edit-scopes"
/>
<p className="text-xs text-ink-muted mt-1">
Default <code>openid profile email</code>. Some IdPs need <code>groups</code> for
the group-claim path; Auth0 namespaces groups under a custom claim. Must include{' '}
<code>openid</code>.
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-ink mb-1">
Groups claim path
</label>
<input
type="text"
value={editGroupsClaimPath}
onChange={e => setEditGroupsClaimPath(e.target.value)}
placeholder="groups"
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink font-mono"
data-testid="oidc-provider-edit-groups-claim-path"
/>
<p className="text-xs text-ink-muted mt-1">
JSON path within the ID token (or userinfo if fallback enabled) that holds the
group list. Common: <code>groups</code>, <code>realm_access.roles</code>
{' '}(Keycloak), namespaced URLs (Auth0).
</p>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">
Groups claim format
</label>
<select
value={editGroupsClaimFormat}
onChange={e => setEditGroupsClaimFormat(e.target.value)}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="oidc-provider-edit-groups-claim-format"
>
<option value="string-array">string-array (default)</option>
<option value="json-path">json-path</option>
</select>
<p className="text-xs text-ink-muted mt-1">
How the IdP encodes the group list. Most IdPs emit a JSON array keep the
default. Use <code>json-path</code> when the claim is a nested object the
path needs to traverse.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-ink mb-1">
IAT window (seconds)
</label>
<input
type="number"
min={1}
max={600}
value={editIATWindow}
onChange={e => setEditIATWindow(Number(e.target.value))}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="oidc-provider-edit-iat-window-seconds"
/>
<p className="text-xs text-ink-muted mt-1">
Maximum ID-token age at consume time (RFC 7519 §4.1.6). Default 300. Range
1600. Tighter = more replay-resistant; looser = more clock-skew-tolerant.
</p>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">
JWKS cache TTL (seconds)
</label>
<input
type="number"
min={60}
value={editJWKSCacheTTL}
onChange={e => setEditJWKSCacheTTL(Number(e.target.value))}
className="w-full px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink"
data-testid="oidc-provider-edit-jwks-cache-ttl-seconds"
/>
<p className="text-xs text-ink-muted mt-1">
How long to cache the IdP's signing-key set before re-fetching. Default 3600
(1h); floor 60. MED-6 auto-refresh-on-cache-miss covers most rotation events;
this knob is for slow-rotation IdPs that want longer caching.
</p>
</div>
</div>
</div>
</details>
</div>
)}
</div>