mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +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:
@@ -24,6 +24,7 @@ import {
|
||||
import { isSecureRequest } from '../helpers/cookies';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { getAuthenticationMode, isLocalLoginEnabled } from '../helpers/authenticationMode';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
@@ -34,6 +35,8 @@ authRouter.get('/status', async (req: Request, res: Response): Promise<void> =>
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
const needsSetup = !settings.auth_username || !settings.auth_password_hash || !settings.auth_jwt_secret;
|
||||
const authenticationMode = getAuthenticationMode();
|
||||
const localLoginEnabled = authenticationMode !== 'sso_only';
|
||||
|
||||
let mfaPending = false;
|
||||
const mfaCookie = req.cookies?.[MFA_PENDING_COOKIE_NAME];
|
||||
@@ -46,10 +49,10 @@ authRouter.get('/status', async (req: Request, res: Response): Promise<void> =>
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ needsSetup, mfaPending });
|
||||
res.json({ needsSetup, mfaPending, localLoginEnabled, authenticationMode });
|
||||
} catch (error) {
|
||||
console.error('Error checking setup status:', error);
|
||||
res.json({ needsSetup: true, mfaPending: false });
|
||||
res.json({ needsSetup: true, mfaPending: false, localLoginEnabled: true, authenticationMode: 'local_and_sso' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -114,6 +117,13 @@ authRouter.post('/login', authRateLimiter, async (req: Request, res: Response):
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isLocalLoginEnabled()) {
|
||||
res.status(403).json({
|
||||
error: 'Local password authentication is disabled. Sign in using SSO.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const user = db.getUserByUsername(username);
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { SSOService } from '../services/SSOService';
|
||||
import { requireAdmin } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import {
|
||||
getAuthenticationMode,
|
||||
isAuthenticationMode,
|
||||
setAuthenticationMode,
|
||||
type AuthenticationMode,
|
||||
} from '../helpers/authenticationMode';
|
||||
|
||||
const SCOPE_MESSAGE = 'API tokens cannot change authentication mode.';
|
||||
|
||||
export const authModeRouter = Router();
|
||||
|
||||
authModeRouter.get('/', (req: Request, res: Response): void => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
try {
|
||||
const mode = getAuthenticationMode();
|
||||
res.json({
|
||||
authenticationMode: mode,
|
||||
localLoginEnabled: mode !== 'sso_only',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AuthMode] Failed to read authentication mode:', error);
|
||||
res.status(500).json({ error: 'Failed to read authentication mode' });
|
||||
}
|
||||
});
|
||||
|
||||
authModeRouter.put('/', async (req: Request, res: Response): Promise<void> => {
|
||||
if (rejectApiTokenScope(req, res, SCOPE_MESSAGE)) return;
|
||||
if (!requireAdmin(req, res)) return;
|
||||
|
||||
const mode = req.body?.mode as unknown;
|
||||
if (!isAuthenticationMode(mode)) {
|
||||
res.status(400).json({ error: 'mode must be local_and_sso or sso_only' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode === 'local_and_sso') {
|
||||
setAuthenticationMode('local_and_sso');
|
||||
console.log('[AuthMode] Authentication mode set to local_and_sso');
|
||||
res.json({
|
||||
success: true,
|
||||
authenticationMode: 'local_and_sso' satisfies AuthenticationMode,
|
||||
localLoginEnabled: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Entering sso_only: safety gates.
|
||||
if (req.body?.confirm !== true) {
|
||||
res.status(400).json({ error: 'confirm must be true to enable SSO-only mode' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = DatabaseService.getInstance();
|
||||
const admin = db.getUser(req.user!.userId);
|
||||
if (!admin || admin.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Administrator access required' });
|
||||
return;
|
||||
}
|
||||
if (admin.auth_provider === 'local') {
|
||||
res.status(400).json({
|
||||
error: 'Sign in with SSO as an administrator before enabling SSO-only mode',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const enabled = db.getEnabledSSOConfigs();
|
||||
if (enabled.length === 0) {
|
||||
res.status(400).json({ error: 'Enable at least one SSO provider before SSO-only mode' });
|
||||
return;
|
||||
}
|
||||
|
||||
const sso = SSOService.getInstance();
|
||||
let anyTestPassed = false;
|
||||
const failures: string[] = [];
|
||||
for (const config of enabled) {
|
||||
const result =
|
||||
config.provider === 'ldap'
|
||||
? await sso.testLdapConnection()
|
||||
: await sso.testOidcDiscovery(config.provider);
|
||||
if (result.success) {
|
||||
anyTestPassed = true;
|
||||
break;
|
||||
}
|
||||
failures.push(`${config.provider}: ${result.error ?? 'connection test failed'}`);
|
||||
}
|
||||
if (!anyTestPassed) {
|
||||
res.status(400).json({
|
||||
error: 'At least one enabled SSO provider must pass a connection test',
|
||||
details: failures,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthenticationMode('sso_only');
|
||||
console.log('[AuthMode] Authentication mode set to sso_only');
|
||||
res.json({
|
||||
success: true,
|
||||
authenticationMode: 'sso_only' satisfies AuthenticationMode,
|
||||
localLoginEnabled: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[AuthMode] Failed to update authentication mode:', error);
|
||||
res.status(500).json({ error: 'Failed to update authentication mode' });
|
||||
}
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { SSOService, type SSOProviderConfig } from '../services/SSOService';
|
||||
import { requireAdmin, requireTierForSsoProvider } from '../middleware/tierGates';
|
||||
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { wouldRemoveLastProvider } from '../helpers/authenticationMode';
|
||||
|
||||
const VALID_SSO_PROVIDERS = ['ldap', 'oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'] as const;
|
||||
const SSO_SCOPE_MESSAGE = 'API tokens cannot access SSO configuration.';
|
||||
@@ -85,6 +86,15 @@ ssoConfigRouter.put('/:provider', (req: Request, res: Response): void => {
|
||||
}
|
||||
}
|
||||
|
||||
const existing = DatabaseService.getInstance().getSSOConfig(provider);
|
||||
const wasEnabled = existing?.enabled === 1;
|
||||
if (!config.enabled && wouldRemoveLastProvider(provider, wasEnabled)) {
|
||||
res.status(400).json({
|
||||
error: 'Cannot disable the last SSO provider while SSO-only mode is active. Switch to Local and SSO first, or use the emergency CLI.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
SSOService.getInstance().saveProviderConfig(config);
|
||||
console.log(`[SSO] Config updated: ${sanitizeForLog(provider)} ${config.enabled ? 'enabled' : 'disabled'}`);
|
||||
res.json({ success: true, message: 'SSO configuration saved' });
|
||||
@@ -101,6 +111,15 @@ ssoConfigRouter.delete('/:provider', (req: Request, res: Response): void => {
|
||||
if (rejectInvalidProvider(provider, res)) return;
|
||||
if (!requireTierForSsoProvider(provider, req, res)) return;
|
||||
try {
|
||||
const existing = DatabaseService.getInstance().getSSOConfig(provider);
|
||||
const wasEnabled = existing?.enabled === 1;
|
||||
if (wouldRemoveLastProvider(provider, wasEnabled)) {
|
||||
res.status(400).json({
|
||||
error: 'Cannot delete the last SSO provider while SSO-only mode is active. Switch to Local and SSO first, or use the emergency CLI.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
SSOService.getInstance().deleteProviderConfig(provider);
|
||||
console.log(`[SSO] Config deleted: ${sanitizeForLog(provider)}`);
|
||||
res.json({ success: true, message: 'SSO configuration deleted' });
|
||||
|
||||
Reference in New Issue
Block a user