fix(auth): honor SSO-only CLI recovery without restart (#1810)

This commit is contained in:
Anso
2026-08-09 23:48:44 -04:00
committed by GitHub
parent 55ca82abb2
commit 27fe0ae837
9 changed files with 86 additions and 19 deletions
+30 -3
View File
@@ -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 ──────────────────────────────────────────────────────────
+2 -1
View File
@@ -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');
});
});
+9 -2
View File
@@ -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 {
+4 -3
View File
@@ -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.',
};
}
+8 -2
View File
@@ -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;
}
+17
View File
@@ -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<Record<string, string>> | null = null;
private auditLogBuffer: Array<Omit<AuditLogEntry, 'id'>> = [];
private auditLogFlushTimer: ReturnType<typeof setTimeout> | 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 {
+3 -5
View File
@@ -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).
<Frame>
<img src="/images/sso/sso-settings.png" alt="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.
</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:
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.
</Accordion>
<Accordion title="'Cannot disable/delete the last SSO provider while SSO-only mode is active'">
+11 -1
View File
@@ -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
+2 -2
View File
@@ -535,7 +535,7 @@ function AuthenticationModePanel({
<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.
{' '}(no restart required).
</p>
)}
@@ -557,7 +557,7 @@ function AuthenticationModePanel({
<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.
{' '}(no restart required).
</p>
<label className="flex items-start gap-2 text-sm">
<Checkbox