diff --git a/backend/package.json b/backend/package.json index 8e11cff2..15e76215 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "create-emergency-admin": "node dist/cli/createEmergencyAdmin.js", "clear-sessions": "node dist/cli/clearSessions.js", "disable-sso": "node dist/cli/disableSso.js", + "enable-local-login": "node dist/cli/enableLocalLogin.js", "diagnostics": "node dist/cli/diagnostics.js", "validate-db": "node dist/cli/validateDb.js", "backup-data": "node dist/cli/backupData.js" diff --git a/backend/src/__tests__/auth-mode.test.ts b/backend/src/__tests__/auth-mode.test.ts new file mode 100644 index 00000000..0851f1a4 --- /dev/null +++ b/backend/src/__tests__/auth-mode.test.ts @@ -0,0 +1,214 @@ +/** + * Authentication mode (SSO-only) route and activation safeguards. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import { + setupTestDb, + cleanupTestDb, + loginAsTestAdmin, + TEST_USERNAME, + TEST_PASSWORD, +} from './helpers/setupTestDb'; +import { setAuthenticationMode } from '../helpers/authenticationMode'; + +let tmpDir: string; +let app: import('express').Express; +let adminCookie: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let LicenseService: typeof import('../services/LicenseService').LicenseService; +let SSOService: typeof import('../services/SSOService').SSOService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ LicenseService } = await import('../services/LicenseService')); + ({ SSOService } = await import('../services/SSOService')); + adminCookie = await loginAsTestAdmin(app); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +function markAdminAsSso(): void { + const db = DatabaseService.getInstance(); + db.getDb() + .prepare("UPDATE users SET auth_provider = 'oidc_custom', provider_id = 'sso-admin-1' WHERE username = ?") + .run(TEST_USERNAME); +} + +function markAdminAsLocal(): void { + const db = DatabaseService.getInstance(); + db.getDb() + .prepare("UPDATE users SET auth_provider = 'local', provider_id = NULL WHERE username = ?") + .run(TEST_USERNAME); +} + +function enableGithubProvider(): void { + DatabaseService.getInstance().upsertSSOConfig( + 'oidc_github', + true, + JSON.stringify({ + provider: 'oidc_github', + enabled: true, + displayName: 'GitHub', + oidcClientId: 'test-client', + }), + ); +} + +beforeEach(() => { + setAuthenticationMode('local_and_sso'); + markAdminAsLocal(); + const db = DatabaseService.getInstance(); + for (const cfg of db.getSSOConfigs()) { + db.upsertSSOConfig(cfg.provider, false, cfg.config_json); + } + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); +}); + +describe('GET /api/sso/auth-mode', () => { + it('returns the current mode for an admin', async () => { + const res = await request(app).get('/api/sso/auth-mode').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.authenticationMode).toBe('local_and_sso'); + expect(res.body.localLoginEnabled).toBe(true); + }); +}); + +describe('PUT /api/sso/auth-mode', () => { + it('rejects PUT sso_only without confirm: true', async () => { + markAdminAsSso(); + enableGithubProvider(); + vi.spyOn(SSOService.getInstance(), 'testOidcDiscovery').mockResolvedValue({ success: true }); + + const missing = await request(app) + .put('/api/sso/auth-mode') + .set('Cookie', adminCookie) + .send({ mode: 'sso_only' }); + expect(missing.status).toBe(400); + expect(missing.body.error).toMatch(/confirm/i); + + const falsy = await request(app) + .put('/api/sso/auth-mode') + .set('Cookie', adminCookie) + .send({ mode: 'sso_only', confirm: false }); + expect(falsy.status).toBe(400); + expect(falsy.body.error).toMatch(/confirm/i); + }); + + it('allows Community admin to enable sso_only', async () => { + markAdminAsSso(); + enableGithubProvider(); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + vi.spyOn(SSOService.getInstance(), 'testOidcDiscovery').mockResolvedValue({ success: true }); + + const res = await request(app) + .put('/api/sso/auth-mode') + .set('Cookie', adminCookie) + .send({ mode: 'sso_only', confirm: true }); + expect(res.status).toBe(200); + expect(res.body.authenticationMode).toBe('sso_only'); + expect(res.body.localLoginEnabled).toBe(false); + }); + + it('rejects local-only admin entering sso_only', async () => { + enableGithubProvider(); + vi.spyOn(SSOService.getInstance(), 'testOidcDiscovery').mockResolvedValue({ success: true }); + + const res = await request(app) + .put('/api/sso/auth-mode') + .set('Cookie', adminCookie) + .send({ mode: 'sso_only', confirm: true }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/Sign in with SSO/i); + }); + + it('rejects sso_only when no provider is enabled', async () => { + markAdminAsSso(); + const res = await request(app) + .put('/api/sso/auth-mode') + .set('Cookie', adminCookie) + .send({ mode: 'sso_only', confirm: true }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/at least one SSO provider/i); + }); + + it('enables sso_only for an SSO admin when a provider test passes', async () => { + markAdminAsSso(); + enableGithubProvider(); + vi.spyOn(SSOService.getInstance(), 'testOidcDiscovery').mockResolvedValue({ success: true }); + + const res = await request(app) + .put('/api/sso/auth-mode') + .set('Cookie', adminCookie) + .send({ mode: 'sso_only', confirm: true }); + expect(res.status).toBe(200); + expect(res.body.authenticationMode).toBe('sso_only'); + expect(res.body.localLoginEnabled).toBe(false); + }); + + it('lets a Community admin revert to local_and_sso (not paid-gated)', async () => { + setAuthenticationMode('sso_only'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + + const res = await request(app) + .put('/api/sso/auth-mode') + .set('Cookie', adminCookie) + .send({ mode: 'local_and_sso' }); + expect(res.status).toBe(200); + expect(res.body.authenticationMode).toBe('local_and_sso'); + expect(res.body.localLoginEnabled).toBe(true); + }); +}); + +describe('Last-provider guard while sso_only', () => { + it('rejects disabling the last enabled provider', async () => { + markAdminAsSso(); + enableGithubProvider(); + setAuthenticationMode('sso_only'); + + const res = await request(app) + .put('/api/sso/config/oidc_github') + .set('Cookie', adminCookie) + .send({ + provider: 'oidc_github', + enabled: false, + displayName: 'GitHub', + oidcClientId: 'test-client', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/last SSO provider/i); + expect(DatabaseService.getInstance().getEnabledSSOConfigs()).toHaveLength(1); + }); + + it('rejects deleting the last enabled provider', async () => { + markAdminAsSso(); + enableGithubProvider(); + setAuthenticationMode('sso_only'); + + const res = await request(app) + .delete('/api/sso/config/oidc_github') + .set('Cookie', adminCookie); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/last SSO provider/i); + }); +}); + +describe('Password change under sso_only', () => { + it('still allows an authenticated password change', async () => { + setAuthenticationMode('local_and_sso'); + const freshCookie = await loginAsTestAdmin(app); + setAuthenticationMode('sso_only'); + + const res = await request(app) + .put('/api/auth/password') + .set('Cookie', freshCookie) + .send({ oldPassword: TEST_PASSWORD, newPassword: TEST_PASSWORD }); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); +}); diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index 0672ea22..952bb49a 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -51,6 +51,54 @@ describe('POST /api/auth/login', () => { const res = await request(app).post('/api/auth/login').send({}); expect(res.status).toBe(400); }); + + it('returns 403 when authentication_mode is sso_only', async () => { + const { setAuthenticationMode } = await import('../helpers/authenticationMode'); + setAuthenticationMode('sso_only'); + try { + const res = await request(app) + .post('/api/auth/login') + .send({ username: TEST_USERNAME, password: TEST_PASSWORD }); + expect(res.status).toBe(403); + expect(res.body.error).toMatch(/Local password authentication is disabled/i); + } finally { + setAuthenticationMode('local_and_sso'); + } + }); +}); + +describe('GET /api/auth/status', () => { + it('reports localLoginEnabled true by default', async () => { + const res = await request(app).get('/api/auth/status'); + expect(res.status).toBe(200); + expect(res.body.localLoginEnabled).toBe(true); + expect(res.body.authenticationMode).toBe('local_and_sso'); + }); + + it('reports localLoginEnabled false when sso_only', async () => { + const { setAuthenticationMode } = await import('../helpers/authenticationMode'); + setAuthenticationMode('sso_only'); + try { + const res = await request(app).get('/api/auth/status'); + expect(res.status).toBe(200); + expect(res.body.localLoginEnabled).toBe(false); + expect(res.body.authenticationMode).toBe('sso_only'); + } finally { + setAuthenticationMode('local_and_sso'); + } + }); + + it('defaults localLoginEnabled to true when the setting key is missing', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const db = DatabaseService.getInstance(); + db.getDb().prepare('DELETE FROM global_settings WHERE key = ?').run('authentication_mode'); + // Bust the settings cache so the next read rebuilds without the deleted key. + const cpu = db.getDb().prepare('SELECT value FROM global_settings WHERE key = ?').get('host_cpu_limit') as { value: string }; + db.updateGlobalSetting('host_cpu_limit', cpu.value); + const res = await request(app).get('/api/auth/status'); + expect(res.status).toBe(200); + expect(res.body.localLoginEnabled).toBe(true); + }); }); // ─── Auth middleware ────────────────────────────────────────────────────────── diff --git a/backend/src/__tests__/recovery-cli.test.ts b/backend/src/__tests__/recovery-cli.test.ts index 3f05e375..ce27b5b0 100644 --- a/backend/src/__tests__/recovery-cli.test.ts +++ b/backend/src/__tests__/recovery-cli.test.ts @@ -15,6 +15,7 @@ let resetPassword: typeof import('../cli/resetPassword').resetPassword; let createEmergencyAdmin: typeof import('../cli/createEmergencyAdmin').createEmergencyAdmin; let clearSessions: typeof import('../cli/clearSessions').clearSessions; let disableSso: typeof import('../cli/disableSso').disableSso; +let enableLocalLogin: typeof import('../cli/enableLocalLogin').enableLocalLogin; let validateDb: typeof import('../cli/validateDb').validateDb; let backupData: typeof import('../cli/backupData').backupData; @@ -25,6 +26,7 @@ beforeAll(async () => { ({ createEmergencyAdmin } = await import('../cli/createEmergencyAdmin')); ({ clearSessions } = await import('../cli/clearSessions')); ({ disableSso } = await import('../cli/disableSso')); + ({ enableLocalLogin } = await import('../cli/enableLocalLogin')); ({ validateDb } = await import('../cli/validateDb')); ({ backupData } = await import('../cli/backupData')); }); @@ -126,6 +128,59 @@ describe('disableSso', () => { expect(result.ok).toBe(true); expect(db.getEnabledSSOConfigs()).toHaveLength(0); }); + + it('rejects disabling the last provider while sso_only', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('authentication_mode', 'sso_only'); + db.upsertSSOConfig('oidc_custom', true, '{"clientId":"abc"}'); + for (const cfg of db.getEnabledSSOConfigs()) { + if (cfg.provider !== 'oidc_custom') { + db.upsertSSOConfig(cfg.provider, false, cfg.config_json); + } + } + const result = disableSso('oidc_custom'); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/last SSO provider/i); + expect(db.getEnabledSSOConfigs()).toHaveLength(1); + expect(db.getGlobalSettings().authentication_mode).toBe('sso_only'); + }); + + it('preserves sso_only when disabling one of several providers', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('authentication_mode', 'sso_only'); + for (const cfg of db.getEnabledSSOConfigs()) { + db.upsertSSOConfig(cfg.provider, false, cfg.config_json); + } + db.upsertSSOConfig('oidc_google', true, '{"clientId":"g"}'); + db.upsertSSOConfig('oidc_github', true, '{"clientId":"h"}'); + const result = disableSso('oidc_google'); + expect(result.ok).toBe(true); + expect(result.message).toMatch(/remains SSO only/i); + expect(db.getGlobalSettings().authentication_mode).toBe('sso_only'); + const remaining = db.getEnabledSSOConfigs().map(c => c.provider).sort(); + expect(remaining).toEqual(['oidc_github']); + }); + + it('restores local_and_sso before disabling all providers under sso_only', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('authentication_mode', 'sso_only'); + db.upsertSSOConfig('oidc_custom', true, '{"clientId":"abc"}'); + const result = disableSso(); + expect(result.ok).toBe(true); + expect(db.getGlobalSettings().authentication_mode).toBe('local_and_sso'); + expect(db.getEnabledSSOConfigs()).toHaveLength(0); + }); +}); + +describe('enableLocalLogin', () => { + it('sets authentication_mode to local_and_sso', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('authentication_mode', 'sso_only'); + const result = enableLocalLogin(); + expect(result.ok).toBe(true); + expect(result.message).toMatch(/Restart Sencho/i); + expect(db.getGlobalSettings().authentication_mode).toBe('local_and_sso'); + }); }); describe('backupData', () => { diff --git a/backend/src/cli/disableSso.ts b/backend/src/cli/disableSso.ts index 63c53d3c..40c799be 100644 --- a/backend/src/cli/disableSso.ts +++ b/backend/src/cli/disableSso.ts @@ -9,42 +9,97 @@ * With no argument it disables every enabled provider. The stored configuration * is preserved (only the enabled flag is cleared) so it can be fixed and * re-enabled from the UI. Written to the audit log with actor `cli`. + * + * When authentication_mode is sso_only and every provider is disabled (no + * argument), this command restores local_and_sso first so the operator is + * never left with SSO-only and zero providers. A named-provider disable that + * would remove the last enabled provider under sso_only is rejected; use the + * no-argument form or enableLocalLogin instead. Disabling one of several + * providers leaves authentication_mode unchanged. */ import { DatabaseService } from '../services/DatabaseService'; +import { + getAuthenticationMode, + setAuthenticationMode, +} from '../helpers/authenticationMode'; import { auditCli, exitWith, type CliResult } from './_shared'; +function restoreLocalLoginIfNeeded(db: DatabaseService): CliResult | null { + const mode = getAuthenticationMode(db); + if (mode !== 'sso_only') return null; + try { + setAuthenticationMode('local_and_sso', db); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { + ok: false, + message: `Failed to re-enable local login before disabling SSO: ${detail}. Providers were left unchanged.`, + }; + } + auditCli(db, '/cli/enable-local-login', 'CLI re-enabled local password authentication before disabling SSO'); + return null; +} + export function disableSso(provider?: string): CliResult { - const db = DatabaseService.getInstance(); + const db = DatabaseService.getInstance(); - if (provider) { - const config = db.getSSOConfig(provider); - if (!config) { - return { ok: false, message: `No SSO config found for provider: ${provider}` }; - } - if (config.enabled !== 1) { - return { ok: true, message: `SSO provider ${provider} is already disabled.` }; - } - db.upsertSSOConfig(provider, false, config.config_json); - auditCli(db, `/cli/disable-sso/${provider}`, `CLI disabled SSO provider ${provider}`); - return { ok: true, message: `Disabled SSO provider ${provider}. Its configuration was preserved.` }; + if (provider) { + const config = db.getSSOConfig(provider); + if (!config) { + return { ok: false, message: `No SSO config found for provider: ${provider}` }; + } + if (config.enabled !== 1) { + return { ok: true, message: `SSO provider ${provider} is already disabled.` }; } - const enabled = db.getEnabledSSOConfigs(); - if (enabled.length === 0) { - return { ok: true, message: 'No SSO providers are currently enabled.' }; + const mode = getAuthenticationMode(db); + if (mode === 'sso_only') { + const enabled = db.getEnabledSSOConfigs(); + if (enabled.length === 1 && enabled[0].provider === provider) { + return { + ok: false, + message: + `Cannot disable the last SSO provider while SSO-only mode is active. ` + + `Run without a provider argument, or run enableLocalLogin first.`, + }; + } } - for (const config of enabled) { - db.upsertSSOConfig(config.provider, false, config.config_json); - } - const names = enabled.map(c => c.provider).join(', '); - auditCli(db, '/cli/disable-sso', `CLI disabled all SSO providers (${enabled.length})`); - return { ok: true, message: `Disabled ${enabled.length} SSO provider(s): ${names}. Configurations were preserved.` }; + + // Named disable leaves authentication_mode unchanged (including sso_only). + db.upsertSSOConfig(provider, false, config.config_json); + auditCli(db, `/cli/disable-sso/${provider}`, `CLI disabled SSO provider ${provider}`); + const modeNote = + mode === 'sso_only' + ? ' Authentication mode remains SSO only; remaining providers stay available.' + : ''; + return { + ok: true, + message: `Disabled SSO provider ${provider}. Its configuration was preserved.${modeNote}`, + }; + } + + const enabled = db.getEnabledSSOConfigs(); + if (enabled.length === 0) { + const modeError = restoreLocalLoginIfNeeded(db); + if (modeError) return modeError; + return { ok: true, message: 'No SSO providers are currently enabled.' }; + } + + const modeError = restoreLocalLoginIfNeeded(db); + if (modeError) return modeError; + + for (const config of enabled) { + db.upsertSSOConfig(config.provider, false, config.config_json); + } + const names = enabled.map(c => c.provider).join(', '); + auditCli(db, '/cli/disable-sso', `CLI disabled all SSO providers (${enabled.length})`); + return { ok: true, message: `Disabled ${enabled.length} SSO provider(s): ${names}. Configurations were preserved.` }; } function main(): void { - exitWith(disableSso(process.argv[2])); + exitWith(disableSso(process.argv[2])); } if (require.main === module) { - main(); + main(); } diff --git a/backend/src/cli/enableLocalLogin.ts b/backend/src/cli/enableLocalLogin.ts new file mode 100644 index 00000000..178ce0da --- /dev/null +++ b/backend/src/cli/enableLocalLogin.ts @@ -0,0 +1,52 @@ +/** + * Emergency CLI: re-enable local password authentication after SSO-only mode + * locks out interactive password login (for example when the identity provider + * is unavailable). + * + * Run via: + * docker compose exec sencho node dist/cli/enableLocalLogin.js + * + * Requires local shell or Docker-host access. Does not contact the identity + * provider. Written to the audit log with actor `cli`. Restart Sencho after + * running so the in-process settings cache picks up the change. + */ +import { DatabaseService } from '../services/DatabaseService'; +import { + getAuthenticationMode, + setAuthenticationMode, +} from '../helpers/authenticationMode'; +import { auditCli, exitWith, type CliResult } from './_shared'; + +export function enableLocalLogin(): CliResult { + const db = DatabaseService.getInstance(); + const current = getAuthenticationMode(db); + if (current === 'local_and_sso') { + return { + ok: true, + message: + 'Local password authentication is already enabled (authentication_mode=local_and_sso).', + }; + } + + try { + setAuthenticationMode('local_and_sso', db); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + return { ok: false, message: `Failed to re-enable local login: ${detail}` }; + } + + auditCli(db, '/cli/enable-local-login', 'CLI re-enabled local password authentication'); + return { + ok: true, + message: + 'Local login re-enabled. Restart Sencho for the change to take effect: docker compose restart sencho', + }; +} + +function main(): void { + exitWith(enableLocalLogin()); +} + +if (require.main === module) { + main(); +} diff --git a/backend/src/helpers/authenticationMode.ts b/backend/src/helpers/authenticationMode.ts new file mode 100644 index 00000000..a355c3da --- /dev/null +++ b/backend/src/helpers/authenticationMode.ts @@ -0,0 +1,37 @@ +import { DatabaseService } from '../services/DatabaseService'; + +export const AUTHENTICATION_MODES = ['local_and_sso', 'sso_only'] as const; +export type AuthenticationMode = (typeof AUTHENTICATION_MODES)[number]; + +export const AUTHENTICATION_MODE_KEY = 'authentication_mode'; +export const DEFAULT_AUTHENTICATION_MODE: AuthenticationMode = 'local_and_sso'; + +/** Read the cached global setting; missing or unknown values default to local_and_sso. */ +export function getAuthenticationMode(db: DatabaseService = DatabaseService.getInstance()): AuthenticationMode { + const raw = db.getGlobalSettings()[AUTHENTICATION_MODE_KEY]; + if (raw === 'sso_only') return 'sso_only'; + return DEFAULT_AUTHENTICATION_MODE; +} + +export function isLocalLoginEnabled(db: DatabaseService = DatabaseService.getInstance()): boolean { + return getAuthenticationMode(db) !== 'sso_only'; +} + +export function setAuthenticationMode( + mode: AuthenticationMode, + db: DatabaseService = DatabaseService.getInstance(), +): void { + db.updateGlobalSetting(AUTHENTICATION_MODE_KEY, mode); +} + +export function isAuthenticationMode(value: unknown): value is AuthenticationMode { + return value === 'local_and_sso' || value === 'sso_only'; +} + +/** True when disabling/deleting this enabled provider would leave zero providers under sso_only. */ +export function wouldRemoveLastProvider(provider: string, currentlyEnabled: boolean): boolean { + if (!currentlyEnabled) return false; + if (isLocalLoginEnabled()) return false; + const enabled = DatabaseService.getInstance().getEnabledSSOConfigs(); + return enabled.length === 1 && enabled[0].provider === provider; +} diff --git a/backend/src/index.ts b/backend/src/index.ts index 6aac5532..55f3b418 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -40,6 +40,7 @@ import { autoHealRouter } from './routes/autoHeal'; import { notificationsRouter, notificationRoutesRouter, notificationSuppressionRouter } from './routes/notifications'; import { consoleRouter } from './routes/console'; import { ssoConfigRouter } from './routes/ssoConfig'; +import { authModeRouter } from './routes/authMode'; import { registriesRouter } from './routes/registries'; import { systemMaintenanceRouter } from './routes/systemMaintenance'; import { volumesRouter } from './routes/volumes'; @@ -139,6 +140,7 @@ app.use('/api/notification-routes', notificationRoutesRouter); app.use('/api/notification-suppression-rules', notificationSuppressionRouter); app.use('/api/system', consoleRouter); app.use('/api/sso/config', ssoConfigRouter); +app.use('/api/sso/auth-mode', authModeRouter); app.use('/api/registries', registriesRouter); app.use('/api/system', systemMaintenanceRouter); app.use('/api/volumes', volumesRouter); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index e8e10f43..fb58b893 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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 => 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 => } } - 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); diff --git a/backend/src/routes/authMode.ts b/backend/src/routes/authMode.ts new file mode 100644 index 00000000..40dc2e2e --- /dev/null +++ b/backend/src/routes/authMode.ts @@ -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 => { + 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' }); + } +}); diff --git a/backend/src/routes/ssoConfig.ts b/backend/src/routes/ssoConfig.ts index ec11845d..d0c13f1f 100644 --- a/backend/src/routes/ssoConfig.ts +++ b/backend/src/routes/ssoConfig.ts @@ -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' }); diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 00937323..1ef69b2f 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -44,6 +44,7 @@ export const CAPABILITIES = [ 'audit-log', 'scheduled-ops', 'sso', + 'authentication-mode', 'api-tokens', 'users', 'registries', diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 0fe44863..88294bca 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -2012,6 +2012,9 @@ export class DatabaseService { stmt.run('cve_intel_enabled', '1'); stmt.run('mesh_auto_recreate', '0'); stmt.run('prune_on_update', '1'); + // Managed by /api/sso/auth-mode, not the generic /api/settings route + // (activation needs safety validation). + stmt.run('authentication_mode', 'local_and_sso'); stmt.run('reclaim_hero', '0'); stmt.run('health_gate_enabled', '1'); stmt.run('health_gate_window_seconds', '90'); diff --git a/backend/src/utils/audit-summaries.ts b/backend/src/utils/audit-summaries.ts index 8d2b854c..8da12ea7 100644 --- a/backend/src/utils/audit-summaries.ts +++ b/backend/src/utils/audit-summaries.ts @@ -106,6 +106,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record = { 'PUT /sso/config': 'Updated SSO configuration', 'DELETE /sso/config': 'Deleted SSO configuration', 'POST /sso/config/*/test': 'Tested SSO configuration', + 'PUT /sso/auth-mode': 'Updated authentication mode', // API tokens 'POST /api-tokens': 'Created API token', diff --git a/docs/features/sso.mdx b/docs/features/sso.mdx index b15a0523..cac68d3b 100644 --- a/docs/features/sso.mdx +++ b/docs/features/sso.mdx @@ -4,9 +4,9 @@ sidebarTitle: SSO and LDAP description: Authenticate with your existing identity provider, including LDAP, Google, GitHub, Okta, and any spec-compliant OIDC provider. --- -Sencho lets your team sign in with the identity provider you already use instead of maintaining a second set of credentials. SSO works **alongside** password authentication; it does not replace it. +Sencho lets your team sign in with the identity provider you already use instead of maintaining a second set of credentials. By default, SSO works alongside local password authentication. You can switch to **SSO only** so interactive local password login is disabled and only configured LDAP or OIDC providers are accepted. -SSO is available on every Sencho tier. Custom OIDC and the preset providers for Google, GitHub, and Okta work on every tier; LDAP and Active Directory require Sencho Admiral. +SSO provider configuration and authentication mode are available on every Sencho tier. Custom OIDC and the preset providers for Google, GitHub, and Okta work on every tier; LDAP and Active Directory require Sencho Admiral. ## Supported providers @@ -78,7 +78,23 @@ SSO can be configured two ways: ### Via Settings UI -Admins manage SSO providers in **Settings → Access → SSO** (admin only; hidden on remote nodes). The masthead shows the SCOPE (global), the number of configured **PROVIDERS**, and how many are **ENABLED**. The page lists every provider as a collapsible card with a label, an **enable / disable** toggle pill on the right, and an **Active** badge on the header when the provider is on. +Admins manage SSO providers in **Settings → Access → SSO** (admin only). The masthead shows the SCOPE (global), the number of configured **PROVIDERS**, and how many are **ENABLED**. The page lists every provider as a collapsible card with a label, an **enable / disable** toggle pill on the right, and an **Active** badge on the header when the provider is on. + +At the top of the page, **Authentication mode** chooses how interactive login works: + +- **Local and SSO** (default): local username/password login remains available alongside configured providers. +- **SSO only**: local password login is disabled. The login page shows only LDAP and/or OIDC providers. Direct calls to the password login endpoint are rejected. When exactly one OIDC provider is enabled and LDAP is not, the login page redirects straight to that provider's authorization endpoint. Multiple OIDC providers still show chooser buttons. A failed SSO attempt that returns to the login page with an error stays on the page so the message is visible. + +SSO only cannot be enabled until at least one provider is enabled, a connection test succeeds, and the signed-in administrator authenticated through SSO with the Admin role. Confirm the outage-risk warning before saving. Existing sessions stay valid until they expire or are revoked. + +If the identity provider is unavailable after SSO only is enabled, restore local password login from the host: + +```bash +docker compose exec sencho node dist/cli/enableLocalLogin.js +docker compose restart sencho +``` + +Restart is required so the running process reloads the setting. The same command is listed under **Settings → System → Recovery**. SSO settings panel listing the five identity providers as collapsible cards with enable / disable toggles @@ -310,7 +326,17 @@ If not set, Sencho auto-detects the URL from the request's `Host` header and pro - Verify the provider is **enabled** (toggle on, showing the **Active** badge) in **Settings → Access → SSO** and that the configuration saved successfully. The login page fetches the list of enabled providers when it loads; hard-refresh the tab if changes were just made. + Confirm the provider is enabled (toggle on, showing the Active badge) under **Settings → Access → SSO** and that the configuration saved successfully. The login page fetches the list of enabled providers when it loads; hard-refresh the tab if changes were just made. + + + From the host that runs Sencho, re-enable local password login and restart so the setting takes effect: + + ```bash + docker compose exec sencho node dist/cli/enableLocalLogin.js + docker compose restart sencho + ``` + + Then sign in with a local administrator account and repair the identity provider configuration before enabling SSO only again. diff --git a/frontend/src/components/Login.test.tsx b/frontend/src/components/Login.test.tsx index be24d4c6..f2c37159 100644 --- a/frontend/src/components/Login.test.tsx +++ b/frontend/src/components/Login.test.tsx @@ -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(); await waitFor(() => expect(screen.getByText('LDAP')).toBeInTheDocument()); await userEvent.click(screen.getByText('LDAP')); diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index f47550df..8dd49827 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -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([]); + 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 (
- Console · Local + {footerLabel} Secure by default
} @@ -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 && (
-
-
- - setUsername(e.target.value)} - className={INPUT_CLASS} - /> -
+ {discoveryReady && blockLocalFallback && ( + {discoveryError} + )} -
-
+ {discoveryReady && oidcRedirecting && ( +
+ + Redirecting to your identity provider... +
+ )} + + {discoveryReady && !blockLocalFallback && !oidcRedirecting && error && !showPasswordForm && ( + {error} + )} + + {discoveryReady && !blockLocalFallback && !oidcRedirecting && showPasswordForm && ( + +
- {capsLock && ( - - Caps Lock On - - )} + setUsername(e.target.value)} + className={INPUT_CLASS} + />
- setPassword(e.target.value)} - onKeyDown={handlePasswordKey} - onKeyUp={handlePasswordKey} - className={INPUT_CLASS} - /> -
-
- setRememberMe(c === true)} - /> -
+ +
+ setRememberMe(c === true)} + /> + +
+ + {error && {error}} + +
+ {isLoading ? ( + <> + + Signing in + + ) : ( + <> + {loginMode === 'ldap' ? 'Sign in with LDAP' : 'Sign in'} + + + )} + + + )} - {error && {error}} - - - - - {oidcProviders.length > 0 && ( + {discoveryReady && !blockLocalFallback && !oidcRedirecting && oidcProviders.length > 0 && (
-
-
- - Or continue with - -
-
-
+ {showPasswordForm && ( +
+
+ + Or continue with + +
+
+ )} +
{oidcProviders.map((p) => (
)} + + {discoveryReady && !blockLocalFallback && !oidcRedirecting && !showPasswordForm && oidcProviders.length === 0 && !discoveryError && ( + No identity providers are available. Contact your administrator. + )}
@@ -263,4 +369,3 @@ function ModePill({ active, label, onClick }: { active: boolean; label: string; ); } - diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 93d1a0ae..87d25aa6 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -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 }: { >
{label} - {initialConfig?.enabled && ( + {config.enabled && ( Active @@ -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('local_and_sso'); + const [loaded, setLoaded] = useState(false); + const [saving, setSaving] = useState(false); + const [confirmRisk, setConfirmRisk] = useState(false); + const [pendingMode, setPendingMode] = useState(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 ( +
+ + Loading authentication mode +
+ ); + } + + return ( +
+
+ +

+ Choose whether local password login remains available alongside SSO. +

+
+ + + + {mode === 'sso_only' && ( +

+ Local password login is disabled. Emergency recovery:{' '} + {ENABLE_LOCAL_LOGIN_CLI} + {' '}then restart Sencho. +

+ )} + + {pendingMode === 'sso_only' && ( +
+

+ Local password login will be disabled. Verify that SSO works and that your + account receives the Admin role before continuing. +

+ {enabledProviderNames.length > 0 ? ( +

+ Providers that will remain available: {enabledProviderNames.join(', ')}. +

+ ) : ( +

+ Enable and test at least one SSO provider before continuing. +

+ )} +

+ If the identity provider is unavailable, recover with{' '} + {ENABLE_LOCAL_LOGIN_CLI} + {' '}and restart Sencho. +

+ +
+ void saveMode('sso_only', true)} + > + {saving ? 'Saving…' : 'Enable SSO only'} + + +
+
+ )} +
+ ); +} + export function SSOSection() { const [configs, setConfigs] = useState([]); @@ -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 (
+ + + +
{PROVIDERS.map(p => ( ({ }, })); +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(); - 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(); + + 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'); + }); }); diff --git a/frontend/src/components/settings/RecoverySection.tsx b/frontend/src/components/settings/RecoverySection.tsx index 8893ee66..6f9dffe3 100644 --- a/frontend/src/components/settings/RecoverySection.tsx +++ b/frontend/src/components/settings/RecoverySection.tsx @@ -41,6 +41,7 @@ const CLI_COMMANDS: Array<{ cmd: string; purpose: string }> = [ { cmd: 'node dist/cli/createEmergencyAdmin.js ', 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' }, diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index 3bb746a1..a50f25fa 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -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', diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index 15eccc9a..97c5f20d 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -22,6 +22,7 @@ export const CAPABILITIES = [ 'audit-log', 'scheduled-ops', 'sso', + 'authentication-mode', 'api-tokens', 'users', 'registries', diff --git a/frontend/src/lib/oidcAutoRedirect.test.ts b/frontend/src/lib/oidcAutoRedirect.test.ts new file mode 100644 index 00000000..05003bb3 --- /dev/null +++ b/frontend/src/lib/oidcAutoRedirect.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/oidcAutoRedirect.ts b/frontend/src/lib/oidcAutoRedirect.ts new file mode 100644 index 00000000..c8985be5 --- /dev/null +++ b/frontend/src/lib/oidcAutoRedirect.ts @@ -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`; +}