mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-29 05:09:10 +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:
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
+30
-4
@@ -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**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/sso/sso-settings.png" alt="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
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="SSO buttons do not appear on the login page">
|
||||
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.
|
||||
</Accordion>
|
||||
<Accordion title="Locked out after enabling SSO only">
|
||||
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.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -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(<Login />);
|
||||
await waitFor(() => expect(screen.getByText('LDAP')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('LDAP'));
|
||||
|
||||
@@ -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<SSOProvider[]>([]);
|
||||
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 (
|
||||
<div className={cn('relative', className)} {...props}>
|
||||
<AuthCanvas
|
||||
footer={
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Console · Local</span>
|
||||
<span>{footerLabel}</span>
|
||||
<span className="text-stat-subtitle/70">Secure by default</span>
|
||||
</div>
|
||||
}
|
||||
@@ -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 && (
|
||||
<div className="mt-1 flex overflow-hidden rounded-md border border-card-border">
|
||||
<ModePill
|
||||
active={loginMode === 'local'}
|
||||
@@ -133,104 +208,131 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
required
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
{discoveryReady && blockLocalFallback && (
|
||||
<ErrorRail>{discoveryError}</ErrorRail>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
{discoveryReady && oidcRedirecting && (
|
||||
<div className="flex items-center justify-center gap-2 text-stat-subtitle">
|
||||
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} aria-hidden />
|
||||
<span className="font-sans text-sm">Redirecting to your identity provider...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discoveryReady && !blockLocalFallback && !oidcRedirecting && error && !showPasswordForm && (
|
||||
<ErrorRail>{error}</ErrorRail>
|
||||
)}
|
||||
|
||||
{discoveryReady && !blockLocalFallback && !oidcRedirecting && showPasswordForm && (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label
|
||||
htmlFor="password"
|
||||
htmlFor="username"
|
||||
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
|
||||
>
|
||||
Password
|
||||
Username
|
||||
</label>
|
||||
{capsLock && (
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning">
|
||||
Caps Lock On
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="admin"
|
||||
required
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handlePasswordKey}
|
||||
onKeyUp={handlePasswordKey}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="remember-me"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(c) => setRememberMe(c === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember-me"
|
||||
className="text-sm text-stat-subtitle cursor-pointer select-none"
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
{capsLock && (
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-warning">
|
||||
Caps Lock On
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={handlePasswordKey}
|
||||
onKeyUp={handlePasswordKey}
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="remember-me"
|
||||
checked={rememberMe}
|
||||
onCheckedChange={(c) => setRememberMe(c === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember-me"
|
||||
className="text-sm text-stat-subtitle cursor-pointer select-none"
|
||||
>
|
||||
Stay signed in
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <ErrorRail>{error}</ErrorRail>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
||||
>
|
||||
Stay signed in
|
||||
</label>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" strokeWidth={1.5} />
|
||||
Signing in
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{loginMode === 'ldap' ? 'Sign in with LDAP' : 'Sign in'}
|
||||
<ArrowRight strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && <ErrorRail>{error}</ErrorRail>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="h-11 w-full bg-brand text-brand-foreground shadow-btn-glow hover:bg-brand/90"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" strokeWidth={1.5} />
|
||||
Signing in
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{loginMode === 'ldap' ? 'Sign in with LDAP' : 'Sign in'}
|
||||
<ArrowRight strokeWidth={1.5} />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{oidcProviders.length > 0 && (
|
||||
{discoveryReady && !blockLocalFallback && !oidcRedirecting && oidcProviders.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-card-border" />
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Or continue with
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-card-border" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{showPasswordForm && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-card-border" />
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Or continue with
|
||||
</span>
|
||||
<div className="h-px flex-1 bg-card-border" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-2',
|
||||
oidcProviders.length === 1 ? 'grid-cols-1 justify-items-center' : 'grid-cols-2',
|
||||
)}
|
||||
>
|
||||
{oidcProviders.map((p) => (
|
||||
<Button
|
||||
key={p.provider}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 justify-center gap-2 font-sans"
|
||||
className={cn(
|
||||
'h-10 justify-center gap-2 font-sans',
|
||||
oidcProviders.length === 1 && 'w-full max-w-[14rem]',
|
||||
)}
|
||||
onClick={() => {
|
||||
window.location.href = `/api/auth/sso/oidc/${p.provider}/authorize`;
|
||||
}}
|
||||
@@ -242,6 +344,10 @@ export function Login({ className, ...props }: React.ComponentPropsWithoutRef<'d
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discoveryReady && !blockLocalFallback && !oidcRedirecting && !showPasswordForm && oidcProviders.length === 0 && !discoveryError && (
|
||||
<ErrorRail>No identity providers are available. Contact your administrator.</ErrorRail>
|
||||
)}
|
||||
</div>
|
||||
</AuthCanvas>
|
||||
</div>
|
||||
@@ -263,4 +369,3 @@ function ModePill({ active, label, onClick }: { active: boolean; label: string;
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { CapabilityGate } from './CapabilityGate';
|
||||
@@ -68,6 +70,13 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; error?: string } | null>(null);
|
||||
const [expanded, setExpanded] = useState(!!initialConfig?.enabled);
|
||||
|
||||
// Keep local draft aligned with the saved config once it loads (or refreshes
|
||||
// after save). Without this, cards mount with enabled:false before fetch
|
||||
// completes and the Active badge (from initialConfig) disagrees with the OFF toggle.
|
||||
useEffect(() => {
|
||||
setConfig(initialConfig || { enabled: false });
|
||||
}, [initialConfig]);
|
||||
|
||||
const update = (field: string, value: string | boolean) => {
|
||||
setConfig(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
@@ -151,7 +160,7 @@ function ProviderCard({ providerId, type, label, initialConfig, onSave }: {
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
{initialConfig?.enabled && (
|
||||
{config.enabled && (
|
||||
<Badge variant="secondary" className="text-xs bg-success-muted text-success border-success/20">
|
||||
Active
|
||||
</Badge>
|
||||
@@ -411,6 +420,178 @@ function ProviderCardWithGate(props: {
|
||||
return card;
|
||||
}
|
||||
|
||||
type AuthMode = 'local_and_sso' | 'sso_only';
|
||||
|
||||
const AUTH_MODE_OPTIONS: Array<{ value: AuthMode; label: string }> = [
|
||||
{ value: 'local_and_sso', label: 'Local and SSO' },
|
||||
{ value: 'sso_only', label: 'SSO only' },
|
||||
];
|
||||
|
||||
const ENABLE_LOCAL_LOGIN_CLI = 'node dist/cli/enableLocalLogin.js';
|
||||
|
||||
function AuthenticationModePanel({
|
||||
enabledProviderNames,
|
||||
}: {
|
||||
enabledProviderNames: string[];
|
||||
}) {
|
||||
const [mode, setMode] = useState<AuthMode>('local_and_sso');
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [confirmRisk, setConfirmRisk] = useState(false);
|
||||
const [pendingMode, setPendingMode] = useState<AuthMode | null>(null);
|
||||
|
||||
const loadMode = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/sso/auth-mode');
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
toast.error(data?.error || 'Failed to load authentication mode');
|
||||
return;
|
||||
}
|
||||
const data = await res.json() as { authenticationMode?: AuthMode };
|
||||
if (data.authenticationMode === 'sso_only' || data.authenticationMode === 'local_and_sso') {
|
||||
setMode(data.authenticationMode);
|
||||
}
|
||||
setLoaded(true);
|
||||
} catch (error: unknown) {
|
||||
toast.error((error as Error)?.message || 'Failed to load authentication mode');
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { void loadMode(); }, []);
|
||||
|
||||
const saveMode = async (next: AuthMode, confirm: boolean) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const body =
|
||||
next === 'sso_only'
|
||||
? { mode: next, confirm }
|
||||
: { mode: next };
|
||||
const res = await apiFetch('/sso/auth-mode', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
toast.error(data?.error || 'Failed to update authentication mode');
|
||||
return;
|
||||
}
|
||||
setMode(next);
|
||||
setPendingMode(null);
|
||||
setConfirmRisk(false);
|
||||
toast.success(
|
||||
next === 'sso_only'
|
||||
? 'SSO-only mode enabled. Local password login is disabled.'
|
||||
: 'Local and SSO mode enabled.',
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
toast.error((error as Error)?.message || 'Failed to update authentication mode');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModeChange = (next: AuthMode) => {
|
||||
if (next === mode) return;
|
||||
if (next === 'sso_only') {
|
||||
setPendingMode('sso_only');
|
||||
setConfirmRisk(false);
|
||||
return;
|
||||
}
|
||||
void saveMode('local_and_sso', false);
|
||||
};
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" strokeWidth={1.5} />
|
||||
Loading authentication mode
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-card-border bg-card/40 p-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
|
||||
Authentication mode
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose whether local password login remains available alongside SSO.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SegmentedControl
|
||||
value={pendingMode ?? mode}
|
||||
options={AUTH_MODE_OPTIONS}
|
||||
onChange={handleModeChange}
|
||||
ariaLabel="Authentication mode"
|
||||
disabled={saving}
|
||||
/>
|
||||
|
||||
{mode === 'sso_only' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Local password login is disabled. Emergency recovery:{' '}
|
||||
<code className="bg-muted px-1 rounded">{ENABLE_LOCAL_LOGIN_CLI}</code>
|
||||
{' '}then restart Sencho.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{pendingMode === 'sso_only' && (
|
||||
<div className="space-y-3 rounded-md border border-warning/40 bg-warning/5 p-3">
|
||||
<p className="text-sm text-stat-value">
|
||||
Local password login will be disabled. Verify that SSO works and that your
|
||||
account receives the Admin role before continuing.
|
||||
</p>
|
||||
{enabledProviderNames.length > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Providers that will remain available: {enabledProviderNames.join(', ')}.
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-destructive">
|
||||
Enable and test at least one SSO provider before continuing.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
If the identity provider is unavailable, recover with{' '}
|
||||
<code className="bg-muted px-1 rounded">{ENABLE_LOCAL_LOGIN_CLI}</code>
|
||||
{' '}and restart Sencho.
|
||||
</p>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={confirmRisk}
|
||||
onCheckedChange={(v) => setConfirmRisk(v === true)}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span>I understand local password login will stop working until re-enabled.</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<SettingsPrimaryButton
|
||||
disabled={saving || !confirmRisk || enabledProviderNames.length === 0}
|
||||
onClick={() => void saveMode('sso_only', true)}
|
||||
>
|
||||
{saving ? 'Saving…' : 'Enable SSO only'}
|
||||
</SettingsPrimaryButton>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={saving}
|
||||
onClick={() => {
|
||||
setPendingMode(null);
|
||||
setConfirmRisk(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SSOSection() {
|
||||
const [configs, setConfigs] = useState<SSOProviderConfig[]>([]);
|
||||
|
||||
@@ -442,10 +623,17 @@ export function SSOSection() {
|
||||
]);
|
||||
|
||||
const getConfig = (provider: string) => configs.find(c => c.provider === provider) || null;
|
||||
const enabledProviderNames = configs
|
||||
.filter(c => c.enabled)
|
||||
.map(c => c.displayName || c.provider);
|
||||
|
||||
return (
|
||||
<CapabilityGate capability="sso" featureName="SSO Authentication">
|
||||
<div className="space-y-6">
|
||||
<CapabilityGate capability="authentication-mode" featureName="Authentication mode">
|
||||
<AuthenticationModePanel enabledProviderNames={enabledProviderNames} />
|
||||
</CapabilityGate>
|
||||
|
||||
<div className="space-y-3">
|
||||
{PROVIDERS.map(p => (
|
||||
<ProviderCardWithGate
|
||||
|
||||
@@ -25,6 +25,10 @@ vi.mock('@/components/ui/toast-store', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
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(<SSOSection />);
|
||||
|
||||
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(<SSOSection />);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ const CLI_COMMANDS: Array<{ cmd: string; purpose: string }> = [
|
||||
{ cmd: 'node dist/cli/createEmergencyAdmin.js <username> <password>', 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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -22,6 +22,7 @@ export const CAPABILITIES = [
|
||||
'audit-log',
|
||||
'scheduled-ops',
|
||||
'sso',
|
||||
'authentication-mode',
|
||||
'api-tokens',
|
||||
'users',
|
||||
'registries',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user