feat(gui/oidc): expose AllowedEmailDomains on create + edit forms (A-3)

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 <dl> 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).
This commit is contained in:
shankar0123
2026-05-11 10:30:37 +00:00
parent 661b6dbefb
commit 8f0af08bd5
5 changed files with 627 additions and 0 deletions
+19
View File
@@ -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
@@ -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(<OIDCProviderDetailPage />);
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(<OIDCProviderDetailPage />);
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(<OIDCProviderDetailPage />);
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(<OIDCProviderDetailPage />);
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(<OIDCProviderDetailPage />);
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();
});
});
});
@@ -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<string[]>([]);
const [emailDomainInput, setEmailDomainInput] = useState('');
const [emailDomainErr, setEmailDomainErr] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(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() {
<dd className="col-span-2">{provider.fetch_userinfo ? 'enabled' : 'disabled'}</dd>
<dt className="text-ink-muted col-span-1">Scopes</dt>
<dd className="col-span-2 font-mono text-xs">{(provider.scopes || []).join(', ')}</dd>
{/* Audit 2026-05-11 A-3 — tenant-isolation gate. Was lying-field
pre-fix: persisted + enforced, but never shown in the GUI. */}
<dt className="text-ink-muted col-span-1">Allowed email domains</dt>
<dd className="col-span-2" data-testid="oidc-provider-detail-allowed-email-domains">
{(provider.allowed_email_domains || []).length === 0 ? (
<span className="text-ink-muted italic">any (no gate configured)</span>
) : (
<div className="flex flex-wrap gap-1">
{(provider.allowed_email_domains || []).map(d => (
<span
key={d}
className="inline-flex items-center px-2 py-0.5 text-xs bg-page border border-surface-border rounded text-ink font-mono"
>
{d}
</span>
))}
</div>
)}
</dd>
<dt className="text-ink-muted col-span-1">IAT window</dt>
<dd className="col-span-2">{provider.iat_window_seconds}s</dd>
</dl>
@@ -262,6 +330,91 @@ export default function OIDCProviderDetailPage() {
/>
<span>Fetch groups from userinfo endpoint when ID token claim is empty</span>
</label>
{/* 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. */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-ink">
Allowed email domains
</label>
{editAllowedEmailDomains.length > 0 && (
<button
type="button"
onClick={clearAllEmailDomains}
className="text-xs text-red-700 hover:underline"
data-testid="oidc-provider-edit-allowed-email-domains-clear-all"
>
Clear all
</button>
)}
</div>
<p className="text-xs text-ink-muted mb-2">
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.
</p>
{editAllowedEmailDomains.length > 0 && (
<div
className="flex flex-wrap gap-1 mb-2"
data-testid="oidc-provider-edit-allowed-email-domains-chips"
>
{editAllowedEmailDomains.map(d => (
<span
key={d}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-page border border-surface-border rounded text-ink font-mono"
data-testid={`oidc-provider-edit-allowed-email-domain-chip-${d}`}
>
{d}
<button
type="button"
onClick={() => removeEmailDomain(d)}
className="text-ink-muted hover:text-red-600 leading-none"
aria-label={`Remove ${d}`}
data-testid={`oidc-provider-edit-allowed-email-domain-chip-remove-${d}`}
>
×
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={emailDomainInput}
onChange={e => {
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"
/>
<button
type="button"
onClick={addEmailDomain}
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink"
data-testid="oidc-provider-edit-allowed-email-domains-add"
>
Add
</button>
</div>
{emailDomainErr && (
<p
className="mt-1 text-xs text-red-700"
data-testid="oidc-provider-edit-allowed-email-domains-error"
>
{emailDomainErr}
</p>
)}
</div>
</div>
)}
</div>
@@ -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(<OIDCProvidersPage />);
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('');
});
});
+124
View File
@@ -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<OIDCProviderRequest>({
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<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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
/>
<span>Fetch groups from userinfo endpoint when ID token claim is empty</span>
</label>
{/* 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. */}
<div>
<label className="block text-sm font-medium text-ink mb-1">
Allowed email domains (optional)
</label>
<p className="text-xs text-ink-muted mb-2">
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.
</p>
{(form.allowed_email_domains || []).length > 0 && (
<div className="flex flex-wrap gap-1 mb-2" data-testid="oidc-create-allowed-email-domains-chips">
{(form.allowed_email_domains || []).map(d => (
<span
key={d}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs bg-page border border-surface-border rounded text-ink font-mono"
data-testid={`oidc-create-allowed-email-domain-chip-${d}`}
>
{d}
<button
type="button"
onClick={() => removeEmailDomain(d)}
className="text-ink-muted hover:text-red-600 leading-none"
aria-label={`Remove ${d}`}
data-testid={`oidc-create-allowed-email-domain-chip-remove-${d}`}
>
×
</button>
</span>
))}
</div>
)}
<div className="flex gap-2">
<input
type="text"
value={emailDomainInput}
onChange={e => {
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"
/>
<button
type="button"
onClick={addEmailDomain}
className="px-3 py-1.5 text-sm border border-surface-border rounded bg-page hover:bg-surface text-ink"
data-testid="oidc-create-allowed-email-domains-add"
>
Add
</button>
</div>
{emailDomainErr && (
<p
className="mt-1 text-xs text-red-700"
data-testid="oidc-create-allowed-email-domains-error"
>
{emailDomainErr}
</p>
)}
</div>
<div className="flex justify-end gap-2 pt-3">
<button
type="button"