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
+214
View File
@@ -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);
});
});
+48
View File
@@ -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 ──────────────────────────────────────────────────────────
@@ -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', () => {