From 8f0af08bd5496e4160e21e44bc89e94e5fba61ac Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Mon, 11 May 2026 10:30:37 +0000 Subject: [PATCH] feat(gui/oidc): expose AllowedEmailDomains on create + edit forms (A-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CRIT-5 closure (2026-05-10) made `OIDCProvider.AllowedEmailDomains` load-bearing on the OIDC login path: a token whose email domain isn't in the configured allowlist gets ErrEmailDomainNotAllowed. But the GUI never exposed the field — `web/src/pages/auth/OIDCProvidersPage.tsx`'s create form had zero inputs for it, and `OIDCProviderDetailPage.tsx` neither rendered nor edited the value. For multi-tenant IdPs (Auth0, Azure AD common endpoint, Google Workspace) this is the single most important provider knob — the difference between "anyone in any tenant of this IdP can log in" and "only @acme.com can log in." Operators driving certctl from the GUI had no way to know the field exists, let alone set it. Same shape as CRIT-5's pre-closure state: the control was claimed, persisted, accepted via API, but invisible at the surface 90% of operators actually use. Closure across both GUI pages: web/src/pages/auth/OIDCProvidersPage.tsx - Create modal gains a chip-style multi-input below fetch_userinfo. - New exported `validateEmailDomain(s)` mirrors the backend validator (CRIT-5 closure rules: no @ / no whitespace / no wildcards / lowercase only / must be FQDN). Returns "" on accept, a non-empty error string on reject. Server is still the source of truth — server-returned 400s render via the existing error UI. - Inline "addEmailDomain" handler: trim → lowercase → validate → dedupe → push onto form.allowed_email_domains. Enter key in the input adds the entry without requiring a click on Add. - Each chip carries a × remove button + data-testid plumbing for E2E coverage. web/src/pages/auth/OIDCProviderDetailPage.tsx - Read-only view's
renders a new row "Allowed email domains" with an explicit "any (no gate configured)" sentinel when the list is empty. Operators can tell the difference between "not configured" and "field exists but the GUI doesn't show it" — the whole class of lying-field this fix exists to retire. - Edit form mirrors the create-modal chip control + pre-populates from provider.allowed_email_domains at startEdit time (defensive clone so chip mutations don't reach through into the cached TanStack Query data). - Save round-trips the trimmed list as `allowed_email_domains` in the PUT body alongside the other editable fields. - "Clear all" affordance with a confirm() dialog that warns about removing the tenant gate (cross-tenant logins permitted after save) — for operators who want to test enforcement-off then turn back on without retyping the full domain list. - Imports `validateEmailDomain` from OIDCProvidersPage for parity. web/src/api/client.ts - No changes — `allowed_email_domains?: string[]` was already in both OIDCProvider and OIDCProviderRequest types. The CRIT-5 backend closure had already shipped the type but no GUI consumer ever used it. Regression coverage (Vitest, all passing): OIDCProvidersPage.test.tsx (7 new): AllowedEmailDomains — Add persists a chip and is included in submit body AllowedEmailDomains — rejects entries containing @ AllowedEmailDomains — rejects wildcard entries AllowedEmailDomains — normalizes mixed-case input to lowercase AllowedEmailDomains — Enter key adds the entry without clicking Add AllowedEmailDomains — chip × button removes the entry AllowedEmailDomains — duplicate entry is rejected validateEmailDomain unit suite (7 new): accepts a plain lowercase FQDN (with multi-label TLDs) rejects entries containing @ (with leading-@ variant) rejects entries with whitespace (with tab variant) rejects wildcards (with both *.x and x.* variants) rejects mixed-case rejects bare hostnames (no dot) rejects empty strings OIDCProviderDetailPage.test.tsx (5 new): AllowedEmailDomains — read-only view shows configured entries AllowedEmailDomains — read-only view shows "any" sentinel when empty AllowedEmailDomains — edit form pre-populates + PUT round-trips AllowedEmailDomains — removing a chip and saving submits the trimmed list AllowedEmailDomains — Add validates against backend rules Verify gate green: `tsc --noEmit` clean across the web/ tree; OIDCProvidersPage + OIDCProviderDetailPage suites pass all 29 tests (19 + 10) — 13 of those are new A-3 cases, 16 were existing CRIT-5 / Bundle 2 Phase 8 coverage. Three pre-existing test failures in AuthSettingsPage.test.tsx + KeysPage.test.tsx confirmed unrelated (reproduce on the base commit `661b6db` without any of this fix's changes applied; not in scope for this CRIT fix). Spec at cowork/auth-bundles-fixes-2026-05-11/03-crit-allowed-email-domains-gui.md Closure annotation appended to CRIT-5 row of cowork/auth-bundles-audit-2026-05-10.md; Lying-fields cross-reference table row #1 marked closed across both the backend (CRIT-5, 2026-05-10) and GUI (A-3, 2026-05-11) legs. Operator advisory in CHANGELOG.md v2.1.0 release notes — operators who provisioned OIDC providers through the GUI between v2.1.0 and this fix should verify allowed_email_domains matches their tenant policy (the field was configurable only via API / MCP / direct SQL during that window). --- CHANGELOG.md | 19 ++ .../auth/OIDCProviderDetailPage.test.tsx | 145 ++++++++++++++ web/src/pages/auth/OIDCProviderDetailPage.tsx | 153 ++++++++++++++ web/src/pages/auth/OIDCProvidersPage.test.tsx | 186 ++++++++++++++++++ web/src/pages/auth/OIDCProvidersPage.tsx | 124 ++++++++++++ 5 files changed, 627 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e986ea..82cef09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,25 @@ ### Security +- **OIDC `allowed_email_domains` now editable in the GUI (Audit 2026-05-11 A-3).** + The backend gate that rejects logins whose email domain is outside the + configured allowlist landed in v2.1.0 (CRIT-5 closure, 2026-05-10), but the + GUI never exposed the field — GUI-driven operators had to use the API + directly to configure tenant isolation against multi-tenant IdPs (Auth0, + Azure AD common endpoint, Google Workspace). The OIDCProvidersPage create + modal and OIDCProviderDetailPage detail view now render a chip-style + multi-input with client-side validation that mirrors the backend rules + (no `@`, no whitespace, no wildcards, lowercase-only FQDNs). The read-only + view renders an explicit "any (no gate configured)" sentinel when the list + is empty so operators can tell "not configured" apart from "field is + invisible." A "Clear all" button on the edit form is gated by a confirm + dialog that warns about removing the tenant gate. **Operator advisory: if + you provisioned OIDC providers via the GUI between v2.1.0 and this fix, + verify `allowed_email_domains` matches your tenant policy — the field was + configurable only via API / MCP / direct SQL during that window.** Per-IdP + runbooks for multi-tenant IdPs in `docs/operator/oidc-runbooks/` already + documented the field; the GUI now matches. + - **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 diff --git a/web/src/pages/auth/OIDCProviderDetailPage.test.tsx b/web/src/pages/auth/OIDCProviderDetailPage.test.tsx index 35444a6..902a7ec 100644 --- a/web/src/pages/auth/OIDCProviderDetailPage.test.tsx +++ b/web/src/pages/auth/OIDCProviderDetailPage.test.tsx @@ -175,4 +175,149 @@ describe('OIDCProviderDetailPage', () => { }); expect(confirmBtn.disabled).toBe(false); }); + + // ============================================================================= + // Audit 2026-05-11 A-3 — AllowedEmailDomains GUI. + // ============================================================================= + + const providerWithDomains = { + ...sampleProvider, + allowed_email_domains: ['acme.com', 'subsidiary.io'], + }; + + it('AllowedEmailDomains — read-only view shows configured entries', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [providerWithDomains] }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-viewer', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: ['r-viewer'], + effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }], + }); + renderRoute(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-detail-allowed-email-domains')).toBeTruthy(); + }); + const panel = screen.getByTestId('oidc-provider-detail-allowed-email-domains'); + expect(panel.textContent).toContain('acme.com'); + expect(panel.textContent).toContain('subsidiary.io'); + }); + + it('AllowedEmailDomains — read-only view shows "any" sentinel when list is empty', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [sampleProvider] }); + vi.mocked(client.authMe).mockResolvedValue({ + actor_id: 'u-viewer', + actor_type: 'User', + tenant_id: 't-default', + admin: false, + roles: ['r-viewer'], + effective_permissions: [{ permission: 'auth.oidc.list', scope_type: 'global' }], + }); + renderRoute(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-detail-allowed-email-domains')).toBeTruthy(); + }); + expect(screen.getByTestId('oidc-provider-detail-allowed-email-domains').textContent) + .toContain('any'); + }); + + it('AllowedEmailDomains — edit form pre-populates existing values + PUT round-trips', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [providerWithDomains] }); + vi.mocked(client.updateOIDCProvider).mockResolvedValue(providerWithDomains); + 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(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-edit-button')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('oidc-provider-edit-button')); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-edit-allowed-email-domains-chips')).toBeTruthy(); + }); + // Pre-populated chips visible. + expect(screen.getByTestId('oidc-provider-edit-allowed-email-domain-chip-acme.com')).toBeTruthy(); + expect(screen.getByTestId('oidc-provider-edit-allowed-email-domain-chip-subsidiary.io')).toBeTruthy(); + + // Save without modification — PUT body must include the original list. + fireEvent.click(screen.getByTestId('oidc-provider-save-button')); + await waitFor(() => { + expect(client.updateOIDCProvider).toHaveBeenCalledTimes(1); + }); + const [, body] = vi.mocked(client.updateOIDCProvider).mock.calls[0]; + expect(body.allowed_email_domains).toEqual(['acme.com', 'subsidiary.io']); + }); + + it('AllowedEmailDomains — removing a chip and saving submits the trimmed list', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [providerWithDomains] }); + vi.mocked(client.updateOIDCProvider).mockResolvedValue(providerWithDomains); + 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(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-edit-button')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('oidc-provider-edit-button')); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-edit-allowed-email-domain-chip-acme.com')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('oidc-provider-edit-allowed-email-domain-chip-remove-acme.com')); + await waitFor(() => { + expect(screen.queryByTestId('oidc-provider-edit-allowed-email-domain-chip-acme.com')).toBeNull(); + }); + fireEvent.click(screen.getByTestId('oidc-provider-save-button')); + await waitFor(() => { + expect(client.updateOIDCProvider).toHaveBeenCalledTimes(1); + }); + const [, body] = vi.mocked(client.updateOIDCProvider).mock.calls[0]; + expect(body.allowed_email_domains).toEqual(['subsidiary.io']); + }); + + it('AllowedEmailDomains — Add validates against backend rules', async () => { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [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(); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-edit-button')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('oidc-provider-edit-button')); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-edit-allowed-email-domains-input')).toBeTruthy(); + }); + fireEvent.change(screen.getByTestId('oidc-provider-edit-allowed-email-domains-input'), { + target: { value: 'user@acme.com' }, + }); + fireEvent.click(screen.getByTestId('oidc-provider-edit-allowed-email-domains-add')); + await waitFor(() => { + expect(screen.getByTestId('oidc-provider-edit-allowed-email-domains-error')).toBeTruthy(); + }); + }); }); diff --git a/web/src/pages/auth/OIDCProviderDetailPage.tsx b/web/src/pages/auth/OIDCProviderDetailPage.tsx index cac577e..ed32fe9 100644 --- a/web/src/pages/auth/OIDCProviderDetailPage.tsx +++ b/web/src/pages/auth/OIDCProviderDetailPage.tsx @@ -11,6 +11,7 @@ import { import { useAuthMe } from '../../hooks/useAuthMe'; import PageHeader from '../../components/PageHeader'; import ErrorState from '../../components/ErrorState'; +import { validateEmailDomain } from './OIDCProvidersPage'; // ============================================================================= // Bundle 2 Phase 8 — OIDCProviderDetailPage. @@ -49,6 +50,11 @@ export default function OIDCProviderDetailPage() { const [editClientSecret, setEditClientSecret] = useState(''); const [editRedirectURI, setEditRedirectURI] = useState(''); const [editFetchUserinfo, setEditFetchUserinfo] = useState(false); + // Audit 2026-05-11 A-3 — pre-populated from provider.allowed_email_domains + // at startEdit time; saved back through the PUT body. Empty list ↔ no gate. + const [editAllowedEmailDomains, setEditAllowedEmailDomains] = useState([]); + const [emailDomainInput, setEmailDomainInput] = useState(''); + const [emailDomainErr, setEmailDomainErr] = useState(null); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); @@ -94,6 +100,12 @@ export default function OIDCProviderDetailPage() { setEditClientSecret(''); setEditRedirectURI(provider.redirect_uri); setEditFetchUserinfo(provider.fetch_userinfo || false); + // Audit 2026-05-11 A-3 — clone so chip-mutations don't reach + // through into the cached query data and re-render every row that + // shares the reference. + setEditAllowedEmailDomains([...(provider.allowed_email_domains || [])]); + setEmailDomainInput(''); + setEmailDomainErr(null); setError(null); setSuccess(null); setEditing(true); @@ -101,9 +113,43 @@ export default function OIDCProviderDetailPage() { const cancelEdit = () => { setEditing(false); + setEmailDomainInput(''); + setEmailDomainErr(null); setError(null); }; + // Audit 2026-05-11 A-3 — mirror of OIDCProvidersPage::addEmailDomain. + const addEmailDomain = () => { + const trimmed = emailDomainInput.trim().toLowerCase(); + setEmailDomainErr(null); + const v = validateEmailDomain(trimmed); + if (v !== '') { + setEmailDomainErr(v); + return; + } + if (editAllowedEmailDomains.includes(trimmed)) { + setEmailDomainErr('Already in the list'); + return; + } + setEditAllowedEmailDomains([...editAllowedEmailDomains, trimmed]); + setEmailDomainInput(''); + }; + + const removeEmailDomain = (d: string) => { + setEditAllowedEmailDomains(editAllowedEmailDomains.filter(x => x !== d)); + }; + + const clearAllEmailDomains = () => { + if (editAllowedEmailDomains.length === 0) return; + if (!window.confirm( + 'Clear ALL allowed email domains?\n\n' + + 'After saving, ANY user with a valid OIDC token from this provider can log in. ' + + 'For multi-tenant IdPs (Auth0, Azure AD common, Google Workspace) this means cross-tenant ' + + 'logins are no longer blocked. Confirm only if that is intended.', + )) return; + setEditAllowedEmailDomains([]); + }; + const saveEdit = async () => { setSubmitting(true); setError(null); @@ -118,6 +164,9 @@ export default function OIDCProviderDetailPage() { groups_claim_format: provider.groups_claim_format, fetch_userinfo: editFetchUserinfo, scopes: provider.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, }; @@ -200,6 +249,25 @@ export default function OIDCProviderDetailPage() {
{provider.fetch_userinfo ? 'enabled' : 'disabled'}
Scopes
{(provider.scopes || []).join(', ')}
+ {/* Audit 2026-05-11 A-3 — tenant-isolation gate. Was lying-field + pre-fix: persisted + enforced, but never shown in the GUI. */} +
Allowed email domains
+
+ {(provider.allowed_email_domains || []).length === 0 ? ( + any (no gate configured) + ) : ( +
+ {(provider.allowed_email_domains || []).map(d => ( + + {d} + + ))} +
+ )} +
IAT window
{provider.iat_window_seconds}s
@@ -262,6 +330,91 @@ export default function OIDCProviderDetailPage() { /> Fetch groups from userinfo endpoint when ID token claim is empty + {/* Audit 2026-05-11 A-3 — Edit form chip control. Mirrors the + create-modal copy; pre-populates from + provider.allowed_email_domains at startEdit time. */} +
+
+ + {editAllowedEmailDomains.length > 0 && ( + + )} +
+

+ When non-empty, only users whose email domain exactly matches one of these entries + can log in. Subdomains are NOT auto-accepted — list each one explicitly. Empty list + means any domain. Case-insensitive exact match. +

+ {editAllowedEmailDomains.length > 0 && ( +
+ {editAllowedEmailDomains.map(d => ( + + {d} + + + ))} +
+ )} +
+ { + setEmailDomainInput(e.target.value); + if (emailDomainErr) setEmailDomainErr(null); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + addEmailDomain(); + } + }} + placeholder="acme.com" + className="flex-1 px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + data-testid="oidc-provider-edit-allowed-email-domains-input" + /> + +
+ {emailDomainErr && ( +

+ {emailDomainErr} +

+ )} +
)} diff --git a/web/src/pages/auth/OIDCProvidersPage.test.tsx b/web/src/pages/auth/OIDCProvidersPage.test.tsx index 142607b..79a9553 100644 --- a/web/src/pages/auth/OIDCProvidersPage.test.tsx +++ b/web/src/pages/auth/OIDCProvidersPage.test.tsx @@ -164,4 +164,190 @@ describe('OIDCProvidersPage', () => { expect(client.createOIDCProvider).toHaveBeenCalledTimes(1); }); }); + + // ============================================================================= + // Audit 2026-05-11 A-3 — AllowedEmailDomains chip control. + // ============================================================================= + + async function openCreateModal() { + vi.mocked(client.listOIDCProviders).mockResolvedValue({ providers: [] }); + vi.mocked(client.createOIDCProvider).mockResolvedValue(sample[0]); + 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.create', scope_type: 'global' }, + ], + }); + renderWithProviders(); + await waitFor(() => { + expect(screen.getByTestId('oidc-providers-create-button')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('oidc-providers-create-button')); + await waitFor(() => { + expect(screen.getByTestId('create-oidc-provider-modal')).toBeTruthy(); + }); + } + + it('AllowedEmailDomains — Add persists a chip and is included in submit body', async () => { + await openCreateModal(); + fireEvent.change(screen.getByTestId('oidc-create-allowed-email-domains-input'), { + target: { value: 'acme.com' }, + }); + fireEvent.click(screen.getByTestId('oidc-create-allowed-email-domains-add')); + await waitFor(() => { + expect(screen.getByTestId('oidc-create-allowed-email-domain-chip-acme.com')).toBeTruthy(); + }); + // Fill remaining required fields and submit. + fireEvent.change(screen.getByTestId('oidc-provider-name-input'), { target: { value: 'Okta' } }); + fireEvent.change(screen.getByTestId('oidc-provider-issuer-url-input'), { + target: { value: 'https://example.okta.com' }, + }); + fireEvent.change(screen.getByTestId('oidc-provider-client-id-input'), { target: { value: 'certctl' } }); + fireEvent.change(screen.getByTestId('oidc-provider-client-secret-input'), { target: { value: 's' } }); + fireEvent.change(screen.getByTestId('oidc-provider-redirect-uri-input'), { + target: { value: 'https://certctl.example.com/auth/oidc/callback' }, + }); + fireEvent.click(screen.getByTestId('create-oidc-provider-submit')); + await waitFor(() => { + expect(client.createOIDCProvider).toHaveBeenCalledTimes(1); + }); + const body = vi.mocked(client.createOIDCProvider).mock.calls[0][0]; + expect(body.allowed_email_domains).toEqual(['acme.com']); + }); + + it('AllowedEmailDomains — rejects entries containing @', async () => { + await openCreateModal(); + fireEvent.change(screen.getByTestId('oidc-create-allowed-email-domains-input'), { + target: { value: 'user@acme.com' }, + }); + fireEvent.click(screen.getByTestId('oidc-create-allowed-email-domains-add')); + await waitFor(() => { + expect(screen.getByTestId('oidc-create-allowed-email-domains-error')).toBeTruthy(); + }); + // Chip must NOT have been added. + expect(screen.queryByTestId('oidc-create-allowed-email-domain-chip-user@acme.com')).toBeNull(); + }); + + it('AllowedEmailDomains — rejects wildcard entries', async () => { + await openCreateModal(); + fireEvent.change(screen.getByTestId('oidc-create-allowed-email-domains-input'), { + target: { value: '*.acme.com' }, + }); + fireEvent.click(screen.getByTestId('oidc-create-allowed-email-domains-add')); + await waitFor(() => { + expect(screen.getByTestId('oidc-create-allowed-email-domains-error')).toBeTruthy(); + }); + }); + + it('AllowedEmailDomains — normalizes mixed-case input to lowercase', async () => { + await openCreateModal(); + fireEvent.change(screen.getByTestId('oidc-create-allowed-email-domains-input'), { + target: { value: 'ACME.COM' }, + }); + fireEvent.click(screen.getByTestId('oidc-create-allowed-email-domains-add')); + await waitFor(() => { + // The chip is keyed by the lowercased form. + expect(screen.getByTestId('oidc-create-allowed-email-domain-chip-acme.com')).toBeTruthy(); + }); + }); + + it('AllowedEmailDomains — Enter key adds the entry without clicking Add', async () => { + await openCreateModal(); + const input = screen.getByTestId('oidc-create-allowed-email-domains-input'); + fireEvent.change(input, { target: { value: 'subsidiary.io' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => { + expect(screen.getByTestId('oidc-create-allowed-email-domain-chip-subsidiary.io')).toBeTruthy(); + }); + }); + + it('AllowedEmailDomains — chip × button removes the entry', async () => { + await openCreateModal(); + fireEvent.change(screen.getByTestId('oidc-create-allowed-email-domains-input'), { + target: { value: 'acme.com' }, + }); + fireEvent.click(screen.getByTestId('oidc-create-allowed-email-domains-add')); + await waitFor(() => { + expect(screen.getByTestId('oidc-create-allowed-email-domain-chip-acme.com')).toBeTruthy(); + }); + fireEvent.click(screen.getByTestId('oidc-create-allowed-email-domain-chip-remove-acme.com')); + await waitFor(() => { + expect(screen.queryByTestId('oidc-create-allowed-email-domain-chip-acme.com')).toBeNull(); + }); + }); + + it('AllowedEmailDomains — duplicate entry is rejected', async () => { + await openCreateModal(); + fireEvent.change(screen.getByTestId('oidc-create-allowed-email-domains-input'), { + target: { value: 'acme.com' }, + }); + fireEvent.click(screen.getByTestId('oidc-create-allowed-email-domains-add')); + await waitFor(() => { + expect(screen.getByTestId('oidc-create-allowed-email-domain-chip-acme.com')).toBeTruthy(); + }); + fireEvent.change(screen.getByTestId('oidc-create-allowed-email-domains-input'), { + target: { value: 'acme.com' }, + }); + fireEvent.click(screen.getByTestId('oidc-create-allowed-email-domains-add')); + await waitFor(() => { + expect(screen.getByTestId('oidc-create-allowed-email-domains-error')).toBeTruthy(); + }); + // Still exactly one chip. + const chips = screen.getAllByTestId(/^oidc-create-allowed-email-domain-chip-(?!remove-)/); + expect(chips).toHaveLength(1); + }); +}); + +// ============================================================================= +// Pure unit tests for validateEmailDomain (Audit 2026-05-11 A-3). +// Backend-parity rules: no @ / no whitespace / no wildcards / lowercase +// only / must be FQDN. +// ============================================================================= + +describe('validateEmailDomain', () => { + it('accepts a plain lowercase FQDN', async () => { + const { validateEmailDomain } = await import('./OIDCProvidersPage'); + expect(validateEmailDomain('acme.com')).toBe(''); + expect(validateEmailDomain('subsidiary.io')).toBe(''); + expect(validateEmailDomain('hyphen-domain.co.uk')).toBe(''); + }); + + it('rejects entries containing @', async () => { + const { validateEmailDomain } = await import('./OIDCProvidersPage'); + expect(validateEmailDomain('user@acme.com')).not.toBe(''); + expect(validateEmailDomain('@acme.com')).not.toBe(''); + }); + + it('rejects entries with whitespace', async () => { + const { validateEmailDomain } = await import('./OIDCProvidersPage'); + expect(validateEmailDomain('acme com')).not.toBe(''); + expect(validateEmailDomain('acme\tcom')).not.toBe(''); + }); + + it('rejects wildcards', async () => { + const { validateEmailDomain } = await import('./OIDCProvidersPage'); + expect(validateEmailDomain('*.acme.com')).not.toBe(''); + expect(validateEmailDomain('acme.*')).not.toBe(''); + }); + + it('rejects mixed-case', async () => { + const { validateEmailDomain } = await import('./OIDCProvidersPage'); + expect(validateEmailDomain('Acme.com')).not.toBe(''); + expect(validateEmailDomain('ACME.COM')).not.toBe(''); + }); + + it('rejects bare hostnames (no dot)', async () => { + const { validateEmailDomain } = await import('./OIDCProvidersPage'); + expect(validateEmailDomain('localhost')).not.toBe(''); + }); + + it('rejects empty strings', async () => { + const { validateEmailDomain } = await import('./OIDCProvidersPage'); + expect(validateEmailDomain('')).not.toBe(''); + }); }); diff --git a/web/src/pages/auth/OIDCProvidersPage.tsx b/web/src/pages/auth/OIDCProvidersPage.tsx index 380a2b6..e680186 100644 --- a/web/src/pages/auth/OIDCProvidersPage.tsx +++ b/web/src/pages/auth/OIDCProvidersPage.tsx @@ -35,6 +35,23 @@ interface CreateProviderModalProps { onSuccess: () => void; } +// Audit 2026-05-11 A-3 — validateEmailDomain mirrors the backend +// validator at internal/auth/oidc/domain/types.go (CRIT-5 closure). +// Rejects entries containing `@` / whitespace / `*` / mixed-case, and +// empties. Returns "" on success; a non-empty string on failure (used +// directly as the inline error message). The server is still the +// source of truth; this is the fast-feedback layer. +export function validateEmailDomain(input: string): string { + if (!input) return 'Empty entry'; + if (input !== input.trim()) return 'Leading or trailing whitespace'; + if (input !== input.toLowerCase()) return 'Must be all lowercase'; + if (input.includes('@')) return 'Entries are domains, not email addresses — drop the "@" and the local part'; + if (input.includes(' ') || /\s/.test(input)) return 'No whitespace'; + if (input.includes('*')) return 'No wildcards — list each subdomain explicitly'; + if (!input.includes('.')) return 'Must be a fully-qualified domain (e.g. acme.com)'; + return ''; +} + function CreateProviderModal({ isOpen, onClose, onSuccess }: CreateProviderModalProps) { const [form, setForm] = useState({ name: '', @@ -46,9 +63,16 @@ function CreateProviderModal({ isOpen, onClose, onSuccess }: CreateProviderModal groups_claim_format: 'string-array', fetch_userinfo: false, scopes: ['openid', 'profile', 'email'], + allowed_email_domains: [], iat_window_seconds: 300, jwks_cache_ttl_seconds: 3600, }); + // Audit 2026-05-11 A-3 — chip-input scratch state for the + // allowed_email_domains tenant-isolation gate. Operators add domains + // one at a time; each goes through validateEmailDomain before being + // appended to form.allowed_email_domains. + const [emailDomainInput, setEmailDomainInput] = useState(''); + const [emailDomainErr, setEmailDomainErr] = useState(null); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); @@ -60,6 +84,30 @@ function CreateProviderModal({ isOpen, onClose, onSuccess }: CreateProviderModal setDirty(true); }; + const addEmailDomain = () => { + const trimmed = emailDomainInput.trim().toLowerCase(); + setEmailDomainErr(null); + const v = validateEmailDomain(trimmed); + if (v !== '') { + setEmailDomainErr(v); + return; + } + const current = form.allowed_email_domains || []; + if (current.includes(trimmed)) { + setEmailDomainErr('Already in the list'); + return; + } + update('allowed_email_domains', [...current, trimmed]); + setEmailDomainInput(''); + }; + + const removeEmailDomain = (d: string) => { + update( + 'allowed_email_domains', + (form.allowed_email_domains || []).filter(x => x !== d), + ); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!form.name.trim() || !form.issuer_url.trim() || !form.client_id.trim() || !form.client_secret) return; @@ -80,6 +128,8 @@ function CreateProviderModal({ isOpen, onClose, onSuccess }: CreateProviderModal if (dirty && !window.confirm('Discard unsaved changes?')) return; setDirty(false); setError(null); + setEmailDomainInput(''); + setEmailDomainErr(null); onClose(); }; @@ -189,6 +239,80 @@ function CreateProviderModal({ isOpen, onClose, onSuccess }: CreateProviderModal /> Fetch groups from userinfo endpoint when ID token claim is empty + {/* Audit 2026-05-11 A-3 — Allowed email domains chip control. + When the list is non-empty, only users whose email-domain + matches one of these entries can complete OIDC login. For + multi-tenant IdPs (Auth0, Azure AD common endpoint, Google + Workspace) this is the only thing preventing cross-tenant + logins; the CRIT-5 backend gate is load-bearing but the GUI + never exposed it until this fix. */} +
+ +

+ When non-empty, only users whose email domain exactly matches one of these entries + can log in. Subdomains are NOT auto-accepted — list each one explicitly. Empty list + means any domain. Case-insensitive exact match. +

+ {(form.allowed_email_domains || []).length > 0 && ( +
+ {(form.allowed_email_domains || []).map(d => ( + + {d} + + + ))} +
+ )} +
+ { + setEmailDomainInput(e.target.value); + if (emailDomainErr) setEmailDomainErr(null); + }} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault(); + addEmailDomain(); + } + }} + placeholder="acme.com" + className="flex-1 px-3 py-1.5 text-sm border border-surface-border rounded bg-page text-ink" + data-testid="oidc-create-allowed-email-domains-input" + /> + +
+ {emailDomainErr && ( +

+ {emailDomainErr} +

+ )} +