mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 14:58:56 +00:00
Merge Fix 07 (HIGH A-7): editable Advanced form on OIDCProviderDetailPage (MED-4)
# Conflicts: # CHANGELOG.md # web/src/pages/auth/OIDCProviderDetailPage.test.tsx # web/src/pages/auth/OIDCProviderDetailPage.tsx
This commit is contained in:
@@ -144,6 +144,26 @@
|
|||||||
stored binding — still passes through unchecked, but that window is
|
stored binding — still passes through unchecked, but that window is
|
||||||
bounded by the 10-minute pre-login TTL.
|
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 `<dl>`
|
||||||
|
also gained the previously-invisible `jwks_cache_ttl_seconds` row.
|
||||||
|
|
||||||
- **Pre-login cookie Path widened from `/auth/oidc/` to `/` (Audit MED-14
|
- **Pre-login cookie Path widened from `/auth/oidc/` to `/` (Audit MED-14
|
||||||
follow-on).** Required to satisfy the `__Host-` prefix's `Path=/` rule. The
|
follow-on).** Required to satisfy the `__Host-` prefix's `Path=/` rule. The
|
||||||
cookie lifetime is unchanged (10 minutes) and only the callback handler
|
cookie lifetime is unchanged (10 minutes) and only the callback handler
|
||||||
|
|||||||
@@ -320,4 +320,178 @@ describe('OIDCProviderDetailPage', () => {
|
|||||||
expect(screen.getByTestId('oidc-provider-edit-allowed-email-domains-error')).toBeTruthy();
|
expect(screen.getByTestId('oidc-provider-edit-allowed-email-domains-error')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ export default function OIDCProviderDetailPage() {
|
|||||||
const [editAllowedEmailDomains, setEditAllowedEmailDomains] = useState<string[]>([]);
|
const [editAllowedEmailDomains, setEditAllowedEmailDomains] = useState<string[]>([]);
|
||||||
const [emailDomainInput, setEmailDomainInput] = useState('');
|
const [emailDomainInput, setEmailDomainInput] = useState('');
|
||||||
const [emailDomainErr, setEmailDomainErr] = useState<string | null>(null);
|
const [emailDomainErr, setEmailDomainErr] = useState<string | null>(null);
|
||||||
|
// 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, 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<number>(300);
|
||||||
|
const [editJWKSCacheTTL, setEditJWKSCacheTTL] = useState<number>(3600);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
@@ -106,6 +126,14 @@ export default function OIDCProviderDetailPage() {
|
|||||||
setEditAllowedEmailDomains([...(provider.allowed_email_domains || [])]);
|
setEditAllowedEmailDomains([...(provider.allowed_email_domains || [])]);
|
||||||
setEmailDomainInput('');
|
setEmailDomainInput('');
|
||||||
setEmailDomainErr(null);
|
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);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
@@ -155,20 +183,62 @@ export default function OIDCProviderDetailPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setSuccess(null);
|
setSuccess(null);
|
||||||
try {
|
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] = {
|
const req: Parameters<typeof updateOIDCProvider>[1] = {
|
||||||
name: editName,
|
name: editName,
|
||||||
issuer_url: editIssuerURL,
|
issuer_url: editIssuerURL,
|
||||||
client_id: editClientID,
|
client_id: editClientID,
|
||||||
redirect_uri: editRedirectURI,
|
redirect_uri: editRedirectURI,
|
||||||
groups_claim_path: provider.groups_claim_path,
|
// Audit 2026-05-11 A-7 — formerly pass-through from
|
||||||
groups_claim_format: provider.groups_claim_format,
|
// 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,
|
fetch_userinfo: editFetchUserinfo,
|
||||||
scopes: provider.scopes,
|
scopes,
|
||||||
// Audit 2026-05-11 A-3 — wire the chip-list value into the PUT
|
// 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.
|
// body. Backend persists [] as no-gate; the field is honest now.
|
||||||
allowed_email_domains: editAllowedEmailDomains,
|
allowed_email_domains: editAllowedEmailDomains,
|
||||||
iat_window_seconds: provider.iat_window_seconds,
|
iat_window_seconds: editIATWindow,
|
||||||
jwks_cache_ttl_seconds: provider.jwks_cache_ttl_seconds,
|
jwks_cache_ttl_seconds: editJWKSCacheTTL,
|
||||||
};
|
};
|
||||||
if (editClientSecret) req.client_secret = editClientSecret;
|
if (editClientSecret) req.client_secret = editClientSecret;
|
||||||
await updateOIDCProvider(provider.id, req);
|
await updateOIDCProvider(provider.id, req);
|
||||||
@@ -270,6 +340,11 @@ export default function OIDCProviderDetailPage() {
|
|||||||
</dd>
|
</dd>
|
||||||
<dt className="text-ink-muted col-span-1">IAT window</dt>
|
<dt className="text-ink-muted col-span-1">IAT window</dt>
|
||||||
<dd className="col-span-2">{provider.iat_window_seconds}s</dd>
|
<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>
|
</dl>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -415,6 +490,121 @@ export default function OIDCProviderDetailPage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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
|
||||||
|
1–600. 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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user