mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
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:
@@ -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'));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -22,6 +22,7 @@ export const CAPABILITIES = [
|
||||
'audit-log',
|
||||
'scheduled-ops',
|
||||
'sso',
|
||||
'authentication-mode',
|
||||
'api-tokens',
|
||||
'users',
|
||||
'registries',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user