mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-14 15:58:56 +00:00
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 `191384c` 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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user