diff --git a/backend/src/__tests__/auth.test.ts b/backend/src/__tests__/auth.test.ts index 952bb49a..4838a487 100644 --- a/backend/src/__tests__/auth.test.ts +++ b/backend/src/__tests__/auth.test.ts @@ -92,13 +92,40 @@ describe('GET /api/auth/status', () => { 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); }); + + it('honors a sidecar CLI write to authentication_mode without clearing the settings cache', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const { setAuthenticationMode, getAuthenticationMode, isLocalLoginEnabled } = await import('../helpers/authenticationMode'); + setAuthenticationMode('sso_only'); + const db = DatabaseService.getInstance(); + // Warm the process cache so a naive getGlobalSettings() read would still + // report sso_only after a direct SQLite write (the enableLocalLogin / + // disableSso sidecar path). + expect(db.getGlobalSettings().authentication_mode).toBe('sso_only'); + db.getDb() + .prepare('INSERT OR REPLACE INTO global_settings (key, value) VALUES (?, ?)') + .run('authentication_mode', 'local_and_sso'); + expect(db.getGlobalSettings().authentication_mode).toBe('sso_only'); + expect(getAuthenticationMode()).toBe('local_and_sso'); + expect(isLocalLoginEnabled()).toBe(true); + + const status = await request(app).get('/api/auth/status'); + expect(status.status).toBe(200); + expect(status.body.localLoginEnabled).toBe(true); + expect(status.body.authenticationMode).toBe('local_and_sso'); + + const login = await request(app) + .post('/api/auth/login') + .send({ username: TEST_USERNAME, password: TEST_PASSWORD }); + expect(login.status).toBe(200); + + // Restore via the normal path so later tests see a coherent cache. + setAuthenticationMode('local_and_sso'); + }); }); // ─── Auth middleware ────────────────────────────────────────────────────────── diff --git a/backend/src/__tests__/recovery-cli.test.ts b/backend/src/__tests__/recovery-cli.test.ts index ce27b5b0..69495729 100644 --- a/backend/src/__tests__/recovery-cli.test.ts +++ b/backend/src/__tests__/recovery-cli.test.ts @@ -167,6 +167,7 @@ describe('disableSso', () => { db.upsertSSOConfig('oidc_custom', true, '{"clientId":"abc"}'); const result = disableSso(); expect(result.ok).toBe(true); + expect(result.message).toMatch(/no restart required/i); expect(db.getGlobalSettings().authentication_mode).toBe('local_and_sso'); expect(db.getEnabledSSOConfigs()).toHaveLength(0); }); @@ -178,7 +179,7 @@ describe('enableLocalLogin', () => { db.updateGlobalSetting('authentication_mode', 'sso_only'); const result = enableLocalLogin(); expect(result.ok).toBe(true); - expect(result.message).toMatch(/Restart Sencho/i); + expect(result.message).toMatch(/no restart is required/i); expect(db.getGlobalSettings().authentication_mode).toBe('local_and_sso'); }); }); diff --git a/backend/src/cli/disableSso.ts b/backend/src/cli/disableSso.ts index 40c799be..ba361f54 100644 --- a/backend/src/cli/disableSso.ts +++ b/backend/src/cli/disableSso.ts @@ -79,10 +79,14 @@ export function disableSso(provider?: string): CliResult { } const enabled = db.getEnabledSSOConfigs(); + const wasSsoOnly = getAuthenticationMode(db) === 'sso_only'; + const modeNote = wasSsoOnly + ? ' Local password login is available again (no restart required).' + : ''; if (enabled.length === 0) { const modeError = restoreLocalLoginIfNeeded(db); if (modeError) return modeError; - return { ok: true, message: 'No SSO providers are currently enabled.' }; + return { ok: true, message: `No SSO providers are currently enabled.${modeNote}` }; } const modeError = restoreLocalLoginIfNeeded(db); @@ -93,7 +97,10 @@ export function disableSso(provider?: string): CliResult { } 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.` }; + return { + ok: true, + message: `Disabled ${enabled.length} SSO provider(s): ${names}. Configurations were preserved.${modeNote}`, + }; } function main(): void { diff --git a/backend/src/cli/enableLocalLogin.ts b/backend/src/cli/enableLocalLogin.ts index 178ce0da..0ed2599a 100644 --- a/backend/src/cli/enableLocalLogin.ts +++ b/backend/src/cli/enableLocalLogin.ts @@ -7,8 +7,9 @@ * 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. + * provider. Written to the audit log with actor `cli`. Takes effect on the + * next login/status request without restarting Sencho (getAuthenticationMode + * reads this key uncached). */ import { DatabaseService } from '../services/DatabaseService'; import { @@ -39,7 +40,7 @@ export function enableLocalLogin(): CliResult { return { ok: true, message: - 'Local login re-enabled. Restart Sencho for the change to take effect: docker compose restart sencho', + 'Local login re-enabled. Sign in with a local administrator password; no restart is required.', }; } diff --git a/backend/src/helpers/authenticationMode.ts b/backend/src/helpers/authenticationMode.ts index a355c3da..aca040bb 100644 --- a/backend/src/helpers/authenticationMode.ts +++ b/backend/src/helpers/authenticationMode.ts @@ -6,9 +6,15 @@ 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. */ +/** + * Read authentication_mode with a fresh SQLite lookup. Must not use the + * getGlobalSettings() process cache: enableLocalLogin / disableSso write this + * key from a sidecar CLI process, and a stale sso_only cache would keep + * rejecting local password login after recovery until Sencho restarts. + * Missing or unknown values default to local_and_sso. + */ export function getAuthenticationMode(db: DatabaseService = DatabaseService.getInstance()): AuthenticationMode { - const raw = db.getGlobalSettings()[AUTHENTICATION_MODE_KEY]; + const raw = db.getGlobalSettingFresh(AUTHENTICATION_MODE_KEY); if (raw === 'sso_only') return 'sso_only'; return DEFAULT_AUTHENTICATION_MODE; } diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 8ab935fe..b987ca03 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1090,6 +1090,9 @@ export class DatabaseService { // so the round-trip to SQLite is worth eliminating. Assumes this // process is the sole writer to global_settings; sidecar tools that // edit the row directly will not invalidate the cache. + // Sidecar CLI writes still leave this cache stale. authentication_mode + // recovery works because getAuthenticationMode uses getGlobalSettingFresh, + // not because this key is omitted from the cache. private cachedGlobalSettings: Readonly> | null = null; private auditLogBuffer: Array> = []; private auditLogFlushTimer: ReturnType | null = null; @@ -3470,6 +3473,20 @@ export class DatabaseService { this.cachedGlobalSettings = null; } + /** + * Read one global_settings value straight from SQLite, ignoring the + * in-process cache. Emergency CLIs (enableLocalLogin, disableSso) write + * from a sidecar Node process; the running server's getGlobalSettings() + * cache cannot see those writes. Use this for settings that must honor + * out-of-process recovery without a restart. + */ + public getGlobalSettingFresh(key: string): string | undefined { + const row = this.db.prepare('SELECT value FROM global_settings WHERE key = ?').get(key) as + | { value: string } + | undefined; + return row?.value; + } + // --- System State (operational/runtime values - not user-defined config) --- public getSystemState(key: string): string | null { diff --git a/docs/features/sso.mdx b/docs/features/sso.mdx index 5a670215..ee50c3a3 100644 --- a/docs/features/sso.mdx +++ b/docs/features/sso.mdx @@ -97,10 +97,9 @@ If the identity provider is unavailable after SSO only is enabled, restore local ```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 → Operations → Recovery**. +No restart is required; the next login attempt honors the restored mode. The same command is listed under **Settings → Operations → Recovery** and in [Emergency command-line recovery](/operations/emergency-cli). SSO settings panel listing the five identity providers as collapsible cards with enable / disable toggles @@ -335,14 +334,13 @@ If not set, Sencho auto-detects the URL from the request's `Host` header and pro Confirm the provider is enabled (toggle on, showing the Active badge) under **Settings → Access → SSO** and that the configuration saved successfully. The login page fetches the list of enabled providers when it loads; hard-refresh the tab if changes were just made. - From the host that runs Sencho, re-enable local password login and restart so the setting takes effect: + From the host that runs Sencho, re-enable local password login: ```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. + Then sign in with a local administrator account and repair the identity provider configuration before enabling SSO only again. No restart is required. diff --git a/docs/operations/emergency-cli.mdx b/docs/operations/emergency-cli.mdx index faecae9a..bdfc5856 100644 --- a/docs/operations/emergency-cli.mdx +++ b/docs/operations/emergency-cli.mdx @@ -80,7 +80,17 @@ docker compose exec sencho node dist/cli/disableSso.js [provider] docker compose exec sencho node dist/cli/disableSso.js oidc_google ``` -With no argument it disables every enabled provider. The stored configuration is preserved (only the enabled flag is cleared), so you can correct it and turn it back on from **Settings · SSO**. +With no argument it disables every enabled provider and, if **SSO only** mode is active, restores **Local and SSO** so password login works again. The stored configuration is preserved (only the enabled flag is cleared), so you can correct it and turn it back on from **Settings · SSO**. No Sencho restart is required. + +### Re-enable local password login (SSO only) + +When authentication mode is **SSO only** and the identity provider is unavailable, restore local password login without disabling providers: + +```bash +docker compose exec sencho node dist/cli/enableLocalLogin.js +``` + +Takes effect on the next login attempt; no restart is required. See [SSO & LDAP Authentication](/features/sso#troubleshooting) for the full SSO-only recovery path. ## Inspecting and protecting your data diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 87d25aa6..925c586b 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -535,7 +535,7 @@ function AuthenticationModePanel({

Local password login is disabled. Emergency recovery:{' '} {ENABLE_LOCAL_LOGIN_CLI} - {' '}then restart Sencho. + {' '}(no restart required).

)} @@ -557,7 +557,7 @@ function AuthenticationModePanel({

If the identity provider is unavailable, recover with{' '} {ENABLE_LOCAL_LOGIN_CLI} - {' '}and restart Sencho. + {' '}(no restart required).