feat(auth): split OIDC_ONLY into granular auth toggles

Replaces the coarse oidc_only + allow_registration settings with four
independent toggles: password_login, password_registration, oidc_login,
oidc_registration. Each can be enabled/disabled individually in
Admin > Settings without affecting the others.

- Add resolveAuthToggles() in authService.ts as the central resolver;
  falls back to legacy oidc_only/allow_registration keys when new keys
  are absent (backward compat)
- OIDC_ONLY env var still works and overrides DB toggles for password_*,
  with a visual lock in the admin UI when active
- Server enforces lockout prevention: cannot disable all login methods
- oidc_login gate added to OIDC /login and /callback routes
- Remove oidc_only toggle from OIDC settings panel; replaced by the
  granular toggles in the Settings tab
- Add 6 new resolveAuthToggles() unit tests; fix AUTH-DB-033 error
  message assertion
- Update OIDC_ONLY descriptions in README, docker-compose, Helm values,
  Unraid template, and .env.example to clarify override semantics

Closes #492
This commit is contained in:
jubnl
2026-04-11 20:21:22 +02:00
parent 2b1889b9a9
commit bfd2553d1e
28 changed files with 439 additions and 76 deletions
+4
View File
@@ -1,6 +1,10 @@
import Database from 'better-sqlite3';
import crypto from 'crypto';
// Seeds run at startup before the DB admin panel can be used, so only env vars
// are checked here. The granular password_login/password_registration DB toggles
// are only relevant after the first user exists; at that point seeds have already
// finished and skip via the userCount > 0 guard above.
function isOidcOnlyConfigured(): boolean {
if (process.env.OIDC_ONLY !== 'true') return false;
return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
+5 -2
View File
@@ -102,13 +102,16 @@ router.get('/oidc', (_req: Request, res: Response) => {
});
router.put('/oidc', (req: Request, res: Response) => {
svc.updateOidcSettings(req.body);
const result = svc.updateOidcSettings(req.body);
if (result.error) {
return res.status(result.status || 400).json({ error: result.error });
}
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'admin.oidc_update',
ip: getClientIp(req),
details: { oidc_only: !!req.body.oidc_only, issuer_set: !!req.body.issuer },
details: { issuer_set: !!req.body.issuer },
});
res.json({ success: true });
});
+9
View File
@@ -15,12 +15,17 @@ import {
frontendUrl,
getAppUrl,
} from '../services/oidcService';
import { resolveAuthToggles } from '../services/authService';
const router = express.Router();
// ---- GET /login ----------------------------------------------------------
router.get('/login', async (req: Request, res: Response) => {
if (!resolveAuthToggles().oidc_login) {
return res.status(403).json({ error: 'SSO login is disabled.' });
}
const config = getOidcConfig();
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
@@ -57,6 +62,10 @@ router.get('/login', async (req: Request, res: Response) => {
// ---- GET /callback -------------------------------------------------------
router.get('/callback', async (req: Request, res: Response) => {
if (!resolveAuthToggles().oidc_login) {
return res.redirect(frontendUrl('/login?oidc_error=sso_disabled'));
}
const { code, state, error: oidcError } = req.query as { code?: string; state?: string; error?: string };
if (oidcError) {
+8 -3
View File
@@ -11,6 +11,7 @@ import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
import { validatePassword } from './passwordPolicy';
import { getPhotoProviderConfig } from './memories/helpersService';
import { send as sendNotification } from './notificationService';
import { resolveAuthToggles } from './authService';
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -254,16 +255,20 @@ export function updateOidcSettings(data: {
client_id?: string;
client_secret?: string;
display_name?: string;
oidc_only?: boolean;
discovery_url?: string;
}) {
}): { error?: string; status?: number; success?: boolean } {
// Lockout prevention: can't remove OIDC config when password login is disabled
if ((data.issuer === '' || data.client_id === '') && !resolveAuthToggles().password_login) {
return { error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.', status: 400 };
}
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
set('oidc_issuer', data.issuer ?? '');
set('oidc_client_id', data.client_id ?? '');
if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? '');
set('oidc_display_name', data.display_name ?? '');
set('oidc_only', data.oidc_only ? 'true' : 'false');
set('oidc_discovery_url', data.discovery_url ?? '');
return { success: true };
}
// ── Demo Baseline ──────────────────────────────────────────────────────────
+68 -18
View File
@@ -31,6 +31,7 @@ const ADMIN_SETTINGS_KEYS = [
'allow_registration', 'allowed_file_types', 'require_mfa',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
'notification_channels', 'admin_webhook_url',
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
];
const avatarDir = path.join(__dirname, '../../uploads/avatars');
@@ -107,16 +108,51 @@ export function avatarUrl(user: { avatar?: string | null }): string | null {
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
}
export function isOidcOnlyMode(): boolean {
export function resolveAuthToggles(): {
password_login: boolean;
password_registration: boolean;
oidc_login: boolean;
oidc_registration: boolean;
} {
const get = (key: string) =>
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
const enabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
if (!enabled) return false;
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null;
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
.some(k => get(k) !== null);
if (hasNewKeys) {
const result = {
password_login: get('password_login') !== 'false',
password_registration: get('password_registration') !== 'false',
oidc_login: get('oidc_login') !== 'false',
oidc_registration: get('oidc_registration') !== 'false',
};
if (process.env.OIDC_ONLY === 'true') {
result.password_login = false;
result.password_registration = false;
}
return result;
}
// Legacy fallback
const oidcOnlyEnabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
const oidcConfigured = !!(
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
);
return oidcConfigured;
const oidcOnly = oidcOnlyEnabled && oidcConfigured;
const allowReg = (get('allow_registration') ?? 'true') === 'true';
return {
password_login: !oidcOnly,
password_registration: !oidcOnly && allowReg,
oidc_login: true,
oidc_registration: allowReg,
};
}
export function isOidcOnlyMode(): boolean {
return !resolveAuthToggles().password_login;
}
export function generateToken(user: { id: number | bigint }) {
@@ -174,9 +210,8 @@ export function getPendingMfaSecret(userId: number): string | null {
export function getAppConfig(authenticatedUser: { id: number } | null) {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
const isDemo = process.env.DEMO_MODE === 'true';
const toggles = resolveAuthToggles();
const { version } = require('../../package.json');
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
const oidcDisplayName = process.env.OIDC_DISPLAY_NAME ||
@@ -185,9 +220,6 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
);
const oidcOnlySetting = process.env.OIDC_ONLY ||
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
@@ -200,14 +232,21 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
return {
allow_registration: isDemo ? false : allowRegistration,
// Legacy fields (backward compat)
allow_registration: isDemo ? false : (toggles.password_registration || toggles.oidc_registration),
oidc_only_mode: !toggles.password_login && !toggles.password_registration,
// Granular toggles
password_login: toggles.password_login,
password_registration: isDemo ? false : toggles.password_registration,
oidc_login: toggles.oidc_login,
oidc_registration: isDemo ? false : toggles.oidc_registration,
env_override_oidc_only: process.env.OIDC_ONLY === 'true',
has_users: userCount > 0,
setup_complete: setupComplete,
version,
has_maps_key: hasGoogleKey,
oidc_configured: oidcConfigured,
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
oidc_only_mode: oidcOnlyMode,
require_mfa: requireMfaRow?.value === 'true',
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
demo_mode: isDemo,
@@ -265,12 +304,9 @@ export function registerUser(body: {
}
if (userCount > 0 && !validInvite) {
if (isOidcOnlyMode()) {
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
}
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
if (setting?.value === 'false') {
return { error: 'Registration is disabled. Contact your administrator.', status: 403 };
const toggles = resolveAuthToggles();
if (!toggles.password_registration) {
return { error: 'Password registration is disabled. Contact your administrator.', status: 403 };
}
}
@@ -707,6 +743,20 @@ export function updateAppSettings(
}
}
// Lockout prevention: can't disable all login methods
if (body.password_login !== undefined || body.oidc_login !== undefined) {
const current = resolveAuthToggles();
const oidcConfigured = !!(
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
);
const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login;
const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login;
if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) {
return { error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 };
}
}
for (const key of ADMIN_SETTINGS_KEYS) {
if (body[key] !== undefined) {
let val = String(body[key]);
+3 -4
View File
@@ -4,6 +4,7 @@ import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { User } from '../types';
import { decrypt_api_key } from './apiKeyCrypto';
import { resolveAuthToggles } from './authService';
// ---------------------------------------------------------------------------
// Types
@@ -269,10 +270,8 @@ export function findOrCreateUser(
}
if (!isFirstUser && !validInvite) {
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as
| { value: string }
| undefined;
if (setting?.value === 'false') {
const { oidc_registration } = resolveAuthToggles();
if (!oidc_registration) {
return { error: 'registration_disabled' };
}
}