feat(auth): add SSO-only authentication mode (#1714)

* feat(auth): add SSO-only authentication mode

Let administrators disable interactive local password login when SSO is configured, with backend enforcement, activation safeguards, and host CLI recovery.

Closes #1709

* fix: resolve CI failures in auth mode PR

- Add useLicense mock to SSOSection test to prevent crash from
  AuthenticationModePanel rendering without LicenseProvider
- Remove username from authMode console.log calls that CodeQL flags
  as clear-text logging of sensitive information

* fix(auth): keep SSO-only on named disableSso and fail-closed login

Named provider disable no longer reverts authentication_mode. Login initializes localLoginEnabled false so a status fetch failure cannot reveal the password form. Center a single OIDC provider button on the login card.

* fix(auth): move SSO-only authentication mode from Admiral to Community tier

Security-hardening features belong on the Community tier per the existing
Community rebalance. The reporter of #1709 noted that disabling local
password login after configuring SSO is a basic security measure, not an
enterprise governance feature. LDAP provider configuration remains
Admiral-gated via requireTierForSsoProvider.

* fix(ui): keep SSO Active badge and ON toggle in sync

Provider cards mounted before config fetch finished with enabled:false, so a saved Active provider showed OFF until the local draft was resynced. Drive both the badge and TogglePill from the synced local config.

* feat(auth): auto-redirect to sole OIDC provider under SSO-only

When authentication mode is SSO only and exactly one OIDC provider is enabled (no LDAP), skip the login chooser and send the browser to that provider's authorize URL. Returning sso_error stays on the login page so the failure message remains visible.

* fix(ui): move oidcAutoRedirectUrl out of Login for fast refresh

Exporting the helper alongside the Login component tripped react-refresh/only-export-components and failed Frontend lint CI. Keep Login as a component-only module and colocate the helper with its unit tests under lib/.
This commit is contained in:
Anso
2026-07-28 10:01:05 -04:00
committed by GitHub
parent 60092dd462
commit e175db8e62
24 changed files with 1216 additions and 134 deletions
+23 -5
View File
@@ -10,13 +10,34 @@ vi.mock('@/context/AuthContext', () => ({
useAuth: () => ({ login: loginMock, ssoLdapLogin: ssoLdapLoginMock }),
}));
function mockAuthDiscovery(providers: Array<{ provider: string; displayName: string; type: string }> = []) {
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/auth/status')) {
return { ok: true, json: async () => ({ localLoginEnabled: true }) };
}
if (url.includes('/api/auth/sso/providers')) {
return { ok: true, json: async () => providers };
}
return { ok: false, json: async () => ({}) };
}),
);
}
beforeEach(() => {
loginMock.mockClear();
ssoLdapLoginMock.mockClear();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] }));
mockAuthDiscovery();
});
async function waitForPasswordForm() {
await waitFor(() => expect(screen.getByLabelText('Username')).toBeInTheDocument());
}
async function fillCredentials() {
await waitForPasswordForm();
await userEvent.type(screen.getByLabelText('Username'), 'admin');
await userEvent.type(screen.getByLabelText('Password'), 'password123');
}
@@ -40,10 +61,7 @@ describe('Login "Stay signed in"', () => {
});
it('threads remember=true through the LDAP form too', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ provider: 'ldap', displayName: 'Directory', type: 'ldap' }],
}));
mockAuthDiscovery([{ provider: 'ldap', displayName: 'Directory', type: 'ldap' }]);
render(<Login />);
await waitFor(() => expect(screen.getByText('LDAP')).toBeInTheDocument());
await userEvent.click(screen.getByText('LDAP'));
+201 -96
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { cn } from '@/lib/utils';
import { oidcAutoRedirectUrl } from '@/lib/oidcAutoRedirect';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
@@ -60,26 +61,90 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d
}
return '';
});
// Capture once: returning from a failed OIDC attempt must stay on Login, not bounce again.
const hadSsoErrorRef = useRef(error.length > 0);
const [isLoading, setIsLoading] = useState(false);
const [loginMode, setLoginMode] = useState<'local' | 'ldap'>('local');
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
const [localLoginEnabled, setLocalLoginEnabled] = useState(false);
const [discoveryError, setDiscoveryError] = useState('');
const [discoveryReady, setDiscoveryReady] = useState(false);
const [oidcRedirecting, setOidcRedirecting] = useState(false);
const [capsLock, setCapsLock] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
useEffect(() => {
fetch('/api/auth/sso/providers', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : []))
.then((providers: SSOProvider[]) => setSsoProviders(providers))
.catch((e) => {
console.warn('[Login] SSO provider discovery failed:', e);
});
let cancelled = false;
(async () => {
try {
const [statusRes, providersRes] = await Promise.all([
fetch('/api/auth/status', { credentials: 'include' }),
fetch('/api/auth/sso/providers', { credentials: 'include' }),
]);
if (cancelled) return;
if (!statusRes.ok) {
// Fail closed: keep localLoginEnabled false so a status outage never
// reveals the password form under SSO-only.
setDiscoveryError('Could not load authentication status. Refresh the page and try again.');
setDiscoveryReady(true);
return;
}
const status = await statusRes.json() as { localLoginEnabled?: boolean };
const enabled = status.localLoginEnabled !== false;
setLocalLoginEnabled(enabled);
if (!providersRes.ok) {
if (!enabled) {
setDiscoveryError('Could not load identity providers. Refresh the page and try again.');
}
setSsoProviders([]);
setDiscoveryReady(true);
return;
}
const providers = await providersRes.json() as SSOProvider[];
const list = Array.isArray(providers) ? providers : [];
const autoUrl = oidcAutoRedirectUrl({
localLoginEnabled: enabled,
providers: list,
hadSsoError: hadSsoErrorRef.current,
});
if (autoUrl) {
if (cancelled) return;
setOidcRedirecting(true);
setDiscoveryReady(true);
window.location.replace(autoUrl);
return;
}
setSsoProviders(list);
if (!enabled && list.some((p) => p.type === 'ldap')) {
setLoginMode('ldap');
}
if (!enabled && list.length === 0) {
setDiscoveryError('No identity providers are available. Contact your administrator.');
}
setDiscoveryReady(true);
} catch (e) {
console.warn('[Login] Auth discovery failed:', e);
if (!cancelled) {
setDiscoveryError('Could not load authentication options. Refresh the page and try again.');
setDiscoveryReady(true);
}
}
})();
return () => { cancelled = true; };
}, []);
const hasLdap = ssoProviders.some((p) => p.type === 'ldap');
const oidcProviders = ssoProviders.filter((p) => p.type === 'oidc');
const showPasswordForm = localLoginEnabled || (hasLdap && loginMode === 'ldap');
const showLocalLdapToggle = localLoginEnabled && hasLdap;
// Fail closed: under SSO-only, never show the local form after a discovery error.
const blockLocalFallback = !localLoginEnabled && !!discoveryError;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!localLoginEnabled && loginMode !== 'ldap') return;
setError('');
setIsLoading(true);
const result =
@@ -96,12 +161,20 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d
}
};
const footerLabel = !discoveryReady
? 'Console'
: !localLoginEnabled
? 'Console · SSO'
: loginMode === 'ldap'
? 'Console · LDAP'
: 'Console · Local';
return (
<div className={cn('relative', className)} {...props}>
<AuthCanvas
footer={
<div className="flex items-center justify-between">
<span>Console · Local</span>
<span>{footerLabel}</span>
<span className="text-stat-subtitle/70">Secure by default</span>
</div>
}
@@ -112,12 +185,14 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d
kicker="AUTHENTICATE"
hero="Sign in"
caption={
loginMode === 'ldap'
? 'Federated via your directory service.'
: 'Self-hosted fleet console.'
!localLoginEnabled
? 'Sign in with your identity provider.'
: loginMode === 'ldap'
? 'Federated via your directory service.'
: 'Self-hosted fleet console.'
}
/>
{hasLdap && (
{showLocalLdapToggle && (
<div className="mt-1 flex overflow-hidden rounded-md border border-card-border">
<ModePill
active={loginMode === 'local'}
@@ -133,104 +208,131 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d
)}
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<label
htmlFor="username"
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
>
Username
</label>
<Input
id="username"
type="text"
placeholder="admin"
required
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={INPUT_CLASS}
/>
</div>
{discoveryReady && blockLocalFallback && (
<ErrorRail>{discoveryError}</ErrorRail>
)}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
{discoveryReady && oidcRedirecting && (
<div className="flex items-center justify-center gap-2 text-stat-subtitle">
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} aria-hidden />
<span className="font-sans text-sm">Redirecting to your identity provider...</span>
</div>
)}
{discoveryReady && !blockLocalFallback && !oidcRedirecting && error && !showPasswordForm && (
<ErrorRail>{error}</ErrorRail>
)}
{discoveryReady && !blockLocalFallback && !oidcRedirecting && showPasswordForm && (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
htmlFor="username"
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
>
Password
Username
</label>
{capsLock && (
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning">
Caps Lock On
</span>
)}
<Input
id="username"
type="text"
placeholder="admin"
required
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={INPUT_CLASS}
/>
</div>
<Input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handlePasswordKey}
onKeyUp={handlePasswordKey}
className={INPUT_CLASS}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="remember-me"
checked={rememberMe}
onCheckedChange={(c) => setRememberMe(c === true)}
/>
<label
htmlFor="remember-me"
className="text-sm text-stat-subtitle cursor-pointer select-none"
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label
htmlFor="password"
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
>
Password
</label>
{capsLock && (
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning">
Caps Lock On
</span>
)}
</div>
<Input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handlePasswordKey}
onKeyUp={handlePasswordKey}
className={INPUT_CLASS}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="remember-me"
checked={rememberMe}
onCheckedChange={(c) => setRememberMe(c === true)}
/>
<label
htmlFor="remember-me"
className="text-sm text-stat-subtitle cursor-pointer select-none"
>
Stay signed in
</label>
</div>
{error && <ErrorRail>{error}</ErrorRail>}
<Button
type="submit"
disabled={isLoading}
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
Stay signed in
</label>
</div>
{isLoading ? (
<>
<Loader2 className="animate-spin" strokeWidth={1.5} />
Signing in
</>
) : (
<>
{loginMode === 'ldap' ? 'Sign in with LDAP' : 'Sign in'}
<ArrowRight strokeWidth={1.5} />
</>
)}
</Button>
</form>
)}
{error && <ErrorRail>{error}</ErrorRail>}
<Button
type="submit"
disabled={isLoading}
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
>
{isLoading ? (
<>
<Loader2 className="animate-spin" strokeWidth={1.5} />
Signing in
</>
) : (
<>
{loginMode === 'ldap' ? 'Sign in with LDAP' : 'Sign in'}
<ArrowRight strokeWidth={1.5} />
</>
)}
</Button>
</form>
{oidcProviders.length > 0 && (
{discoveryReady && !blockLocalFallback && !oidcRedirecting && oidcProviders.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-card-border" />
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Or continue with
</span>
<div className="h-px flex-1 bg-card-border" />
</div>
<div className="grid grid-cols-2 gap-2">
{showPasswordForm && (
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-card-border" />
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Or continue with
</span>
<div className="h-px flex-1 bg-card-border" />
</div>
)}
<div
className={cn(
'grid gap-2',
oidcProviders.length === 1 ? 'grid-cols-1 justify-items-center' : 'grid-cols-2',
)}
>
{oidcProviders.map((p) => (
<Button
key={p.provider}
type="button"
variant="outline"
className="h-10 justify-center gap-2 font-sans"
className={cn(
'h-10 justify-center gap-2 font-sans',
oidcProviders.length === 1 && 'w-full max-w-[14rem]',
)}
onClick={() => {
window.location.href = `/api/auth/sso/oidc/${p.provider}/authorize`;
}}
@@ -242,6 +344,10 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d
</div>
</div>
)}
{discoveryReady && !blockLocalFallback && !oidcRedirecting && !showPasswordForm && oidcProviders.length === 0 && !discoveryError && (
<ErrorRail>No identity providers are available. Contact your administrator.</ErrorRail>
)}
</div>
</AuthCanvas>
</div>
@@ -263,4 +369,3 @@ function ModePill({ active, label, onClick }: { active: boolean; label: string;
</button>
);
}
+189 -1
View File
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Combobox } from '@/components/ui/combobox';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { SegmentedControl } from '@/components/ui/segmented-control';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { CapabilityGate } from './CapabilityGate';
@@ -68,6 +70,13 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null);
const [expanded, setExpanded] = useState(!!initialConfig?.enabled);
// Keep local draft aligned with the saved config once it loads (or refreshes
// after save). Without this, cards mount with enabled:false before fetch
// completes and the Active badge (from initialConfig) disagrees with the OFF toggle.
useEffect(() => {
setConfig(initialConfig || { enabled: false });
}, [initialConfig]);
const update = (field: string, value: string | boolean) => {
setConfig(prev => ({ ...prev, [field]: value }));
};
@@ -151,7 +160,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
>
<div className="flex items-center gap-3">
<span className="font-medium text-sm">{label}</span>
{initialConfig?.enabled && (
{config.enabled && (
<Badge variant="secondary" className="text-xs bg-success-muted text-success border-success/20">
Active
</Badge>
@@ -411,6 +420,178 @@ function ProviderCardWithGate(props: {
return card;
}
type AuthMode = 'local_and_sso' | 'sso_only';
const AUTH_MODE_OPTIONS: Array<{ value: AuthMode; label: string }> = [
{ value: 'local_and_sso', label: 'Local and SSO' },
{ value: 'sso_only', label: 'SSO only' },
];
const ENABLE_LOCAL_LOGIN_CLI = 'node dist/cli/enableLocalLogin.js';
function AuthenticationModePanel({
enabledProviderNames,
}: {
enabledProviderNames: string[];
}) {
const [mode, setMode] = useState<AuthMode>('local_and_sso');
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [confirmRisk, setConfirmRisk] = useState(false);
const [pendingMode, setPendingMode] = useState<AuthMode | null>(null);
const loadMode = async () => {
try {
const res = await apiFetch('/sso/auth-mode');
if (!res.ok) {
const data = await res.json().catch(() => null);
toast.error(data?.error || 'Failed to load authentication mode');
return;
}
const data = await res.json() as { authenticationMode?: AuthMode };
if (data.authenticationMode === 'sso_only' || data.authenticationMode === 'local_and_sso') {
setMode(data.authenticationMode);
}
setLoaded(true);
} catch (error: unknown) {
toast.error((error as Error)?.message || 'Failed to load authentication mode');
}
};
// eslint-disable-next-line react-hooks/set-state-in-effect
useEffect(() => { void loadMode(); }, []);
const saveMode = async (next: AuthMode, confirm: boolean) => {
setSaving(true);
try {
const body =
next === 'sso_only'
? { mode: next, confirm }
: { mode: next };
const res = await apiFetch('/sso/auth-mode', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
toast.error(data?.error || 'Failed to update authentication mode');
return;
}
setMode(next);
setPendingMode(null);
setConfirmRisk(false);
toast.success(
next === 'sso_only'
? 'SSO-only mode enabled. Local password login is disabled.'
: 'Local and SSO mode enabled.',
);
} catch (error: unknown) {
toast.error((error as Error)?.message || 'Failed to update authentication mode');
} finally {
setSaving(false);
}
};
const handleModeChange = (next: AuthMode) => {
if (next === mode) return;
if (next === 'sso_only') {
setPendingMode('sso_only');
setConfirmRisk(false);
return;
}
void saveMode('local_and_sso', false);
};
if (!loaded) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
Loading authentication mode
</div>
);
}
return (
<div className="space-y-3 rounded-md border border-card-border bg-card/40 p-4">
<div className="space-y-1">
<Label className="font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
Authentication mode
</Label>
<p className="text-xs text-muted-foreground">
Choose whether local password login remains available alongside SSO.
</p>
</div>
<SegmentedControl
value={pendingMode ?? mode}
options={AUTH_MODE_OPTIONS}
onChange={handleModeChange}
ariaLabel="Authentication mode"
disabled={saving}
/>
{mode === 'sso_only' && (
<p className="text-xs text-muted-foreground">
Local password login is disabled. Emergency recovery:{' '}
<code className="bg-muted px-1 rounded">{ENABLE_LOCAL_LOGIN_CLI}</code>
{' '}then restart Sencho.
</p>
)}
{pendingMode === 'sso_only' && (
<div className="space-y-3 rounded-md border border-warning/40 bg-warning/5 p-3">
<p className="text-sm text-stat-value">
Local password login will be disabled. Verify that SSO works and that your
account receives the Admin role before continuing.
</p>
{enabledProviderNames.length > 0 ? (
<p className="text-xs text-muted-foreground">
Providers that will remain available: {enabledProviderNames.join(', ')}.
</p>
) : (
<p className="text-xs text-destructive">
Enable and test at least one SSO provider before continuing.
</p>
)}
<p className="text-xs text-muted-foreground">
If the identity provider is unavailable, recover with{' '}
<code className="bg-muted px-1 rounded">{ENABLE_LOCAL_LOGIN_CLI}</code>
{' '}and restart Sencho.
</p>
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={confirmRisk}
onCheckedChange={(v) => setConfirmRisk(v === true)}
disabled={saving}
/>
<span>I understand local password login will stop working until re-enabled.</span>
</label>
<div className="flex gap-2">
<SettingsPrimaryButton
disabled={saving || !confirmRisk || enabledProviderNames.length === 0}
onClick={() => void saveMode('sso_only', true)}
>
{saving ? 'Saving' : 'Enable SSO only'}
</SettingsPrimaryButton>
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => {
setPendingMode(null);
setConfirmRisk(false);
}}
>
Cancel
</Button>
</div>
</div>
)}
</div>
);
}
export function SSOSection() {
const [configs, setConfigs] = useState<SSOProviderConfig[]>([]);
@@ -442,10 +623,17 @@ export function SSOSection() {
]);
const getConfig = (provider: string) => configs.find(c => c.provider === provider) || null;
const enabledProviderNames = configs
.filter(c => c.enabled)
.map(c => c.displayName || c.provider);
return (
<CapabilityGate capability="sso" featureName="SSO Authentication">
<div className="space-y-6">
<CapabilityGate capability="authentication-mode" featureName="Authentication mode">
<AuthenticationModePanel enabledProviderNames={enabledProviderNames} />
</CapabilityGate>
<div className="space-y-3">
{PROVIDERS.map(p => (
<ProviderCardWithGate
@@ -25,6 +25,10 @@ vi.mock('@/components/ui/toast-store', () => ({
},
}));
vi.mock('@/context/LicenseContext', () => ({
useLicense: () => ({ isPaid: true }),
}));
// Render the gated cards directly; tier/capability gating is exercised in the
// backend suite and is not what this test is about.
vi.mock('../CapabilityGate', () => ({
@@ -101,16 +105,49 @@ describe('SSOSection error surfacing', () => {
if (path === '/sso/config') {
return Promise.resolve(res(true, [{ provider: 'oidc_custom', enabled: true, displayName: 'Custom OIDC' }]));
}
if (path === '/sso/auth-mode') {
return Promise.resolve(res(true, { authenticationMode: 'local_and_sso', localLoginEnabled: true }));
}
if (opts?.method === 'DELETE') return Promise.resolve(res(false, { error: 'delete rejected' }));
return Promise.resolve(res(true, {}));
});
render(<SSOSection />);
await user.click(await screen.findByText('Custom OIDC'));
await waitFor(() => {
expect(screen.getByText('Active')).toBeInTheDocument();
});
await user.click(screen.getByText('Custom OIDC'));
await user.click(screen.getByRole('button', { name: /Remove/i }));
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('delete rejected');
});
});
it('keeps the Active badge and ON toggle in sync after an enabled config loads', async () => {
mockedFetch.mockImplementation((path: string) => {
if (path === '/sso/config') {
return Promise.resolve(res(true, [{
provider: 'oidc_github',
enabled: true,
displayName: 'GitHub',
oidcClientId: 'client',
}]));
}
if (path === '/sso/auth-mode') {
return Promise.resolve(res(true, { authenticationMode: 'local_and_sso', localLoginEnabled: true }));
}
return Promise.resolve(res(true, {}));
});
render(<SSOSection />);
await waitFor(() => {
expect(screen.getByText('Active')).toBeInTheDocument();
});
const onSwitches = screen.getAllByRole('switch').filter(
(el) => el.getAttribute('aria-checked') === 'true',
);
expect(onSwitches).toHaveLength(1);
expect(onSwitches[0]).toHaveTextContent('ON');
});
});
@@ -41,6 +41,7 @@ const CLI_COMMANDS: Array<{ cmd: string; purpose: string }> = [
{ cmd: 'node dist/cli/createEmergencyAdmin.js <username> <password>', purpose: 'Create a new admin account' },
{ cmd: 'node dist/cli/clearSessions.js', purpose: 'Sign every user out' },
{ cmd: 'node dist/cli/disableSso.js [provider]', purpose: 'Disable a broken SSO provider' },
{ cmd: 'node dist/cli/enableLocalLogin.js', purpose: 'Re-enable local password login after SSO-only mode' },
{ cmd: 'node dist/cli/diagnostics.js', purpose: 'Print this report as JSON' },
{ cmd: 'node dist/cli/validateDb.js', purpose: 'Check database and encryption-key integrity' },
{ cmd: 'node dist/cli/backupData.js [dir]', purpose: 'Back up the data directory' },
+1 -2
View File
@@ -93,11 +93,10 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
group: 'access',
label: 'SSO',
description: 'Single sign-on via OIDC or LDAP identity providers.',
keywords: ['saml', 'oidc', 'okta', 'entra', 'azure', 'login'],
keywords: ['saml', 'oidc', 'okta', 'entra', 'azure', 'login', 'authentication mode'],
tier: null,
scope: 'global',
adminOnly: true,
hiddenOnRemote: true,
},
{
id: 'api-tokens',
+1
View File
@@ -22,6 +22,7 @@ export const CAPABILITIES = [
'audit-log',
'scheduled-ops',
'sso',
'authentication-mode',
'api-tokens',
'users',
'registries',
+85
View File
@@ -0,0 +1,85 @@
/**
* SSO-only single-OIDC auto-redirect decision matrix.
*
* Under SSO-only with exactly one OIDC provider (and no LDAP), Login should
* send the browser to that provider's authorize URL. Local+SSO mode, LDAP,
* multiple OIDC providers, and a returning sso_error must not auto-redirect.
*/
import { describe, it, expect } from 'vitest';
import { oidcAutoRedirectUrl } from './oidcAutoRedirect';
const github = { provider: 'oidc_github', type: 'oidc' };
const google = { provider: 'oidc_google', type: 'oidc' };
const ldap = { provider: 'ldap', type: 'ldap' };
describe('oidcAutoRedirectUrl', () => {
it('returns the authorize URL for SSO-only with a single OIDC provider', () => {
expect(
oidcAutoRedirectUrl({
localLoginEnabled: false,
providers: [github],
hadSsoError: false,
}),
).toBe('/api/auth/sso/oidc/oidc_github/authorize');
});
it('returns null when local password login is still enabled', () => {
expect(
oidcAutoRedirectUrl({
localLoginEnabled: true,
providers: [github],
hadSsoError: false,
}),
).toBeNull();
});
it('returns null when more than one OIDC provider is configured', () => {
expect(
oidcAutoRedirectUrl({
localLoginEnabled: false,
providers: [github, google],
hadSsoError: false,
}),
).toBeNull();
});
it('returns null when LDAP is present alongside a single OIDC provider', () => {
expect(
oidcAutoRedirectUrl({
localLoginEnabled: false,
providers: [github, ldap],
hadSsoError: false,
}),
).toBeNull();
});
it('returns null for LDAP-only SSO-only (no authorization endpoint)', () => {
expect(
oidcAutoRedirectUrl({
localLoginEnabled: false,
providers: [ldap],
hadSsoError: false,
}),
).toBeNull();
});
it('returns null after an SSO error so the login page can show the message', () => {
expect(
oidcAutoRedirectUrl({
localLoginEnabled: false,
providers: [github],
hadSsoError: true,
}),
).toBeNull();
});
it('returns null when no providers are configured', () => {
expect(
oidcAutoRedirectUrl({
localLoginEnabled: false,
providers: [],
hadSsoError: false,
}),
).toBeNull();
});
});
+12
View File
@@ -0,0 +1,12 @@
/** Authorize URL when SSO-only has exactly one OIDC provider and no LDAP; otherwise null. */
export function oidcAutoRedirectUrl(opts: {
localLoginEnabled: boolean;
providers: Array<{ provider: string; type: string }>;
hadSsoError: boolean;
}): string | null {
if (opts.localLoginEnabled || opts.hadSsoError) return null;
if (opts.providers.some((p) => p.type === 'ldap')) return null;
const oidc = opts.providers.filter((p) => p.type === 'oidc');
if (oidc.length !== 1) return null;
return `/api/auth/sso/oidc/${oidc[0].provider}/authorize`;
}