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