mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08: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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user