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} +

+ )} +