mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 11:16:55 +00:00
fix(auth): honor SSO-only CLI recovery without restart (#1810)
This commit is contained in:
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user