mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -44,6 +44,7 @@ export const CAPABILITIES = [
|
||||
'audit-log',
|
||||
'scheduled-ops',
|
||||
'sso',
|
||||
'authentication-mode',
|
||||
'api-tokens',
|
||||
'users',
|
||||
'registries',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -106,6 +106,7 @@ export const AUDIT_ROUTE_SUMMARIES: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
Reference in New Issue
Block a user