mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user