diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0da3cf3..fbbbaea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -144,6 +144,26 @@
stored binding — still passes through unchecked, but that window is
bounded by the 10-minute pre-login TTL.
+- **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 1–600; 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 `
` 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, 1–600; 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(300);
+ const [editJWKSCacheTTL, setEditJWKSCacheTTL] = useState(3600);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const [success, setSuccess] = useState(null);
@@ -106,6 +126,14 @@ export default function OIDCProviderDetailPage() {
setEditAllowedEmailDomains([...(provider.allowed_email_domains || [])]);
setEmailDomainInput('');
setEmailDomainErr(null);
+ // 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);
@@ -155,20 +183,62 @@ 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[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 `
` 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,
+ scopes,
// Audit 2026-05-11 A-3 — wire the chip-list value into the PUT
// body. Backend persists [] as no-gate; the field is honest now.
allowed_email_domains: editAllowedEmailDomains,
- iat_window_seconds: provider.iat_window_seconds,
- jwks_cache_ttl_seconds: provider.jwks_cache_ttl_seconds,
+ iat_window_seconds: editIATWindow,
+ jwks_cache_ttl_seconds: editJWKSCacheTTL,
};
if (editClientSecret) req.client_secret = editClientSecret;
await updateOIDCProvider(provider.id, req);
@@ -270,6 +340,11 @@ export default function OIDCProviderDetailPage() {
IAT window
{provider.iat_window_seconds}s
+ {/* Audit 2026-05-11 A-7 — JWKS cache TTL surfaced in
+ read-only view too (pre-fix the value was persisted but
+ invisible). */}
+
JWKS cache TTL
+
{provider.jwks_cache_ttl_seconds}s
) : (
@@ -415,6 +490,121 @@ export default function OIDCProviderDetailPage() {
)}
+
+ {/* Audit 2026-05-11 A-7 — Advanced section. Five fields the
+ read-only
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. */}
+
+
+ Advanced (scopes, groups claim, IAT / JWKS TTL)
+
+
+ Default openid profile email. Some IdPs need groups for
+ the group-claim path; Auth0 namespaces groups under a custom claim. Must include{' '}
+ openid.
+
+ JSON path within the ID token (or userinfo if fallback enabled) that holds the
+ group list. Common: groups, realm_access.roles
+ {' '}(Keycloak), namespaced URLs (Auth0).
+
+
+
+
+
+
+ How the IdP encodes the group list. Most IdPs emit a JSON array — keep the
+ default. Use json-path when the claim is a nested object the
+ path needs to traverse.
+
+ Maximum ID-token age at consume time (RFC 7519 §4.1.6). Default 300. Range
+ 1–600. Tighter = more replay-resistant; looser = more clock-skew-tolerant.
+
+ 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.
+