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
+2 -2
View File
@@ -171,7 +171,7 @@ services:
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set to true to disable local password auth entirely (SSO only)
# - OIDC_ONLY=false # Set to true to force SSO-only login (disables password login and registration). Equivalent to toggling those off in Admin > Settings, but takes priority over any DB setting and cannot be changed at runtime.
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
@@ -320,7 +320,7 @@ trek.yourdomain.com {
| `OIDC_CLIENT_ID` | OIDC client ID | — |
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
| `OIDC_ONLY` | Disable local password auth entirely (first SSO login becomes admin) | `false` |
| `OIDC_ONLY` | Force SSO-only mode: disables password login and password registration, regardless of the granular toggles in Admin > Settings. The first SSO login becomes admin. Use when you want this enforced at the infrastructure level and not overridable via the UI. | `false` |
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
| `OIDC_SCOPE` | Space-separated OIDC scopes to request. **Fully replaces** the default — always include `openid email profile` plus any extra scopes you need (e.g. add `groups` when using `OIDC_ADMIN_CLAIM`) | `openid email profile` |
+3 -1
View File
@@ -40,7 +40,9 @@ env:
# OIDC_DISPLAY_NAME: "SSO"
# Label shown on the SSO login button.
# OIDC_ONLY: "false"
# Set to "true" to disable local password auth entirely (first SSO login becomes admin).
# Set to "true" to force SSO-only mode: disables password login and password registration.
# Overrides the granular toggles in Admin > Settings and cannot be changed at runtime.
# First SSO login becomes admin on a fresh instance.
# OIDC_ADMIN_CLAIM: ""
# OIDC claim used to identify admin users.
# OIDC_ADMIN_VALUE: ""
+11
View File
@@ -525,6 +525,17 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'admin.invite.deleteError': 'فشل حذف رابط الدعوة',
'admin.allowRegistration': 'السماح بالتسجيل',
'admin.allowRegistrationHint': 'يمكن للمستخدمين الجدد التسجيل بأنفسهم',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'فرض المصادقة الثنائية (2FA)',
'admin.requireMfaHint': 'يجب على المستخدمين الذين لا يملكون 2FA إكمال الإعداد في الإعدادات قبل استخدام التطبيق.',
'admin.apiKeys': 'مفاتيح API',
+11
View File
@@ -489,6 +489,17 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Configurações',
'admin.allowRegistration': 'Permitir cadastro',
'admin.allowRegistrationHint': 'Novos usuários podem se cadastrar sozinhos',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Exigir autenticação em dois fatores (2FA)',
'admin.requireMfaHint': 'Usuários sem 2FA precisam concluir a configuração em Configurações antes de usar o app.',
'admin.apiKeys': 'Chaves de API',
+11
View File
@@ -489,6 +489,17 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Nastavení',
'admin.allowRegistration': 'Povolit registraci',
'admin.allowRegistrationHint': 'Noví uživatelé se mohou sami registrovat',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Vyžadovat dvoufázové ověření (2FA)',
'admin.requireMfaHint': 'Uživatelé bez 2FA musí dokončit nastavení v Nastavení před použitím aplikace.',
'admin.apiKeys': 'API klíče',
+11
View File
@@ -493,6 +493,17 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Einstellungen',
'admin.allowRegistration': 'Registrierung erlauben',
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Zwei-Faktor-Authentifizierung (2FA) für alle verlangen',
'admin.requireMfaHint': 'Benutzer ohne 2FA müssen die Einrichtung unter Einstellungen abschließen, bevor sie die App nutzen können.',
'admin.apiKeys': 'API-Schlüssel',
+11
View File
@@ -518,6 +518,17 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Settings',
'admin.allowRegistration': 'Allow Registration',
'admin.allowRegistrationHint': 'New users can register themselves',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Require two-factor authentication (2FA)',
'admin.requireMfaHint': 'Users without 2FA must complete setup in Settings before using the app.',
'admin.apiKeys': 'API Keys',
+11
View File
@@ -487,6 +487,17 @@ const es: Record<string, string> = {
'admin.tabs.settings': 'Ajustes',
'admin.allowRegistration': 'Permitir el registro',
'admin.allowRegistrationHint': 'Los nuevos usuarios pueden registrarse por sí mismos',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Exigir autenticación en dos factores (2FA)',
'admin.requireMfaHint': 'Los usuarios sin 2FA deben completar la configuración en Ajustes antes de usar la aplicación.',
'admin.apiKeys': 'Claves API',
+11
View File
@@ -489,6 +489,17 @@ const fr: Record<string, string> = {
'admin.tabs.settings': 'Paramètres',
'admin.allowRegistration': 'Autoriser les inscriptions',
'admin.allowRegistrationHint': 'Les nouveaux utilisateurs peuvent s\'inscrire eux-mêmes',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Exiger l\'authentification à deux facteurs (2FA)',
'admin.requireMfaHint': 'Les utilisateurs sans 2FA doivent terminer la configuration dans Paramètres avant d\'utiliser l\'application.',
'admin.apiKeys': 'Clés API',
+11
View File
@@ -489,6 +489,17 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Beállítások',
'admin.allowRegistration': 'Regisztráció engedélyezése',
'admin.allowRegistrationHint': 'Új felhasználók regisztrálhatják magukat',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Kétlépcsős hitelesítés (2FA) kötelezővé tétele',
'admin.requireMfaHint': 'A 2FA nélküli felhasználóknak a Beállításokban kell befejezniük a beállítást az alkalmazás használata előtt.',
'admin.apiKeys': 'API kulcsok',
+11
View File
@@ -489,6 +489,17 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Impostazioni',
'admin.allowRegistration': 'Consenti Registrazione',
'admin.allowRegistrationHint': 'I nuovi utenti possono registrarsi autonomamente',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Richiedi autenticazione a due fattori (2FA)',
'admin.requireMfaHint': 'Gli utenti senza 2FA devono completare la configurazione in Impostazioni prima di usare l\'app.',
'admin.apiKeys': 'Chiavi API',
+11
View File
@@ -490,6 +490,17 @@ const nl: Record<string, string> = {
'admin.tabs.settings': 'Instellingen',
'admin.allowRegistration': 'Registratie toestaan',
'admin.allowRegistrationHint': 'Nieuwe gebruikers kunnen zichzelf registreren',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Tweestapsverificatie (2FA) verplicht stellen',
'admin.requireMfaHint': 'Gebruikers zonder 2FA moeten de installatie in Instellingen voltooien voordat ze de app kunnen gebruiken.',
'admin.apiKeys': 'API-sleutels',
+11
View File
@@ -461,6 +461,17 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'admin.tabs.settings': 'Ustawienia',
'admin.allowRegistration': 'Zezwól na rejestrację',
'admin.allowRegistrationHint': 'Nowi użytkownicy mogą się rejestrować samodzielnie',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Wymagaj uwierzytelniania dwuskładnikowego (2FA)',
'admin.requireMfaHint': 'Użytkownicy bez 2FA muszą ukończyć konfigurację w Ustawieniach zanim zaczną korzystać z aplikacji.',
'admin.apiKeys': 'Klucze API',
+11
View File
@@ -490,6 +490,17 @@ const ru: Record<string, string> = {
'admin.tabs.settings': 'Настройки',
'admin.allowRegistration': 'Разрешить регистрацию',
'admin.allowRegistrationHint': 'Новые пользователи могут регистрироваться самостоятельно',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': 'Требовать двухфакторную аутентификацию (2FA)',
'admin.requireMfaHint': 'Пользователи без 2FA должны завершить настройку в разделе «Настройки» перед использованием приложения.',
'admin.apiKeys': 'API-ключи',
+11
View File
@@ -490,6 +490,17 @@ const zh: Record<string, string> = {
'admin.tabs.settings': '设置',
'admin.allowRegistration': '允许注册',
'admin.allowRegistrationHint': '新用户可以自行注册',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': '要求双因素身份验证(2FA',
'admin.requireMfaHint': '未启用 2FA 的用户必须先完成设置中的配置才能使用应用。',
'admin.apiKeys': 'API 密钥',
+11
View File
@@ -515,6 +515,17 @@ const zhTw: Record<string, string> = {
'admin.tabs.settings': '設定',
'admin.allowRegistration': '允許註冊',
'admin.allowRegistrationHint': '新使用者可以自行註冊',
'admin.authMethods': 'Authentication Methods',
'admin.passwordLogin': 'Password Login',
'admin.passwordLoginHint': 'Allow users to sign in with email and password',
'admin.passwordRegistration': 'Password Registration',
'admin.passwordRegistrationHint': 'Allow new users to register with email and password',
'admin.oidcLogin': 'SSO Login',
'admin.oidcLoginHint': 'Allow users to sign in with SSO',
'admin.oidcRegistration': 'SSO Auto-Provisioning',
'admin.oidcRegistrationHint': 'Automatically create accounts for new SSO users',
'admin.envOverrideHint': 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.',
'admin.lockoutWarning': 'At least one login method must remain enabled',
'admin.requireMfa': '要求雙因素身份驗證(2FA',
'admin.requireMfaHint': '未啟用 2FA 的使用者必須先完成設定中的配置才能使用應用。',
'admin.apiKeys': 'API 金鑰',
+94 -37
View File
@@ -46,7 +46,6 @@ interface OidcConfig {
client_secret: string
client_secret_set: boolean
display_name: string
oidc_only: boolean
discovery_url: string
}
@@ -192,11 +191,16 @@ export default function AdminPage(): React.ReactElement {
useEffect(() => { adminApi.getBagTracking().then(d => setBagTrackingEnabled(d.enabled)).catch(() => {}) }, [])
// OIDC config
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' })
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' })
const [savingOidc, setSavingOidc] = useState<boolean>(false)
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
// Auth toggles
const [passwordLogin, setPasswordLogin] = useState<boolean>(true)
const [passwordRegistration, setPasswordRegistration] = useState<boolean>(true)
const [oidcLogin, setOidcLogin] = useState<boolean>(true)
const [oidcRegistration, setOidcRegistration] = useState<boolean>(true)
const [envOverrideOidcOnly, setEnvOverrideOidcOnly] = useState<boolean>(false)
const [oidcConfigured, setOidcConfigured] = useState<boolean>(false)
const [requireMfa, setRequireMfa] = useState<boolean>(false)
// Invite links
@@ -268,7 +272,12 @@ export default function AdminPage(): React.ReactElement {
const loadAppConfig = async () => {
try {
const config = await authApi.getAppConfig()
setAllowRegistration(config.allow_registration)
setPasswordLogin(config.password_login ?? true)
setPasswordRegistration(config.password_registration ?? config.allow_registration ?? true)
setOidcLogin(config.oidc_login ?? true)
setOidcRegistration(config.oidc_registration ?? config.allow_registration ?? true)
setEnvOverrideOidcOnly(config.env_override_oidc_only ?? false)
setOidcConfigured(config.oidc_configured ?? false)
if (config.require_mfa !== undefined) setRequireMfa(!!config.require_mfa)
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
} catch (err: unknown) {
@@ -286,12 +295,12 @@ export default function AdminPage(): React.ReactElement {
}
}
const handleToggleRegistration = async (value) => {
setAllowRegistration(value)
const handleToggleAuthSetting = async (key: string, value: boolean, setter: (v: boolean) => void) => {
setter(value)
try {
await authApi.updateAppSettings({ allow_registration: value })
await authApi.updateAppSettings({ [key]: value })
} catch (err: unknown) {
setAllowRegistration(!value)
setter(!value)
toast.error(getApiErrorMessage(err, t('common.error')))
}
}
@@ -792,28 +801,94 @@ export default function AdminPage(): React.ReactElement {
{activeTab === 'settings' && (
<div className="space-y-6">
{/* Registration Toggle */}
{/* Authentication Methods */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.allowRegistration')}</h2>
<h2 className="font-semibold text-slate-900">{t('admin.authMethods')}</h2>
</div>
<div className="p-6">
<div className="p-6 space-y-5">
{envOverrideOidcOnly && (
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
{t('admin.envOverrideHint')}
</p>
)}
{/* Password Login */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.allowRegistration')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.allowRegistrationHint')}</p>
<p className="text-sm font-medium text-slate-700">{t('admin.passwordLogin')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.passwordLoginHint')}</p>
</div>
<button
onClick={() => handleToggleRegistration(!allowRegistration)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: allowRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
disabled={envOverrideOidcOnly || (!passwordLogin && !oidcLogin)}
onClick={() => handleToggleAuthSetting('password_login', !passwordLogin, setPasswordLogin)}
title={!passwordLogin && !oidcLogin ? t('admin.lockoutWarning') : undefined}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50"
style={{ background: passwordLogin ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: allowRegistration ? 'translateX(20px)' : 'translateX(0)' }}
style={{ transform: passwordLogin ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
{/* Password Registration */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.passwordRegistration')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.passwordRegistrationHint')}</p>
</div>
<button
disabled={envOverrideOidcOnly}
onClick={() => handleToggleAuthSetting('password_registration', !passwordRegistration, setPasswordRegistration)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50"
style={{ background: passwordRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: passwordRegistration ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
{/* SSO Login (only when OIDC configured) */}
{oidcConfigured && (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.oidcLogin')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcLoginHint')}</p>
</div>
<button
disabled={!passwordLogin && oidcLogin}
onClick={() => handleToggleAuthSetting('oidc_login', !oidcLogin, setOidcLogin)}
title={!passwordLogin && oidcLogin ? t('admin.lockoutWarning') : undefined}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-50"
style={{ background: oidcLogin ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: oidcLogin ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
)}
{/* SSO Registration (only when OIDC configured) */}
{oidcConfigured && (
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.oidcRegistration')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcRegistrationHint')}</p>
</div>
<button
onClick={() => handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
style={{ background: oidcRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: oidcRegistration ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
)}
</div>
</div>
@@ -1036,29 +1111,11 @@ export default function AdminPage(): React.ReactElement {
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
{/* OIDC-only mode toggle */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.oidcOnlyMode')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.oidcOnlyModeHint')}</p>
</div>
<button
onClick={() => setOidcConfig(c => ({ ...c, oidc_only: !c.oidc_only }))}
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors flex-shrink-0 ml-4"
style={{ background: oidcConfig.oidc_only ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
<span
className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
style={{ transform: oidcConfig.oidc_only ? 'translateX(20px)' : 'translateX(0)' }}
/>
</button>
</div>
<button
onClick={async () => {
setSavingOidc(true)
try {
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, oidc_only: oidcConfig.oidc_only, discovery_url: oidcConfig.discovery_url }
const payload: Record<string, unknown> = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name, discovery_url: oidcConfig.discovery_url }
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
await adminApi.updateOidc(payload)
toast.success(t('admin.oidcSaved'))
+10 -5
View File
@@ -15,6 +15,11 @@ interface AppConfig {
oidc_configured: boolean
oidc_display_name?: string
oidc_only_mode: boolean
password_login: boolean
password_registration: boolean
oidc_login: boolean
oidc_registration: boolean
env_override_oidc_only: boolean
}
export default function LoginPage(): React.ReactElement {
@@ -104,7 +109,7 @@ export default function LoginPage(): React.ReactElement {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
if (config.oidc_only_mode && config.oidc_configured && config.has_users && !invite && !noRedirect) {
if (!config.password_login && config.oidc_login && config.oidc_configured && config.has_users && !invite && !noRedirect) {
window.location.href = '/api/auth/oidc/login'
}
}
@@ -194,10 +199,10 @@ export default function LoginPage(): React.ReactElement {
}
}
const showRegisterOption = (appConfig?.allow_registration || !appConfig?.has_users || inviteValid) && !appConfig?.oidc_only_mode && (appConfig?.setup_complete !== false || !appConfig?.has_users)
const showRegisterOption = (appConfig?.password_registration || !appConfig?.has_users || inviteValid) && (appConfig?.setup_complete !== false || !appConfig?.has_users)
// In OIDC-only mode, show a minimal page that redirects directly to the IdP
const oidcOnly = appConfig?.oidc_only_mode && appConfig?.oidc_configured
const oidcOnly = !appConfig?.password_login && appConfig?.oidc_login && appConfig?.oidc_configured
const inputBase: React.CSSProperties = {
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
@@ -730,8 +735,8 @@ export default function LoginPage(): React.ReactElement {
</>)}
</div>
{/* OIDC / SSO login button (only when OIDC is configured but not in oidc-only mode) */}
{appConfig?.oidc_configured && !oidcOnly && (
{/* OIDC / SSO login button (only when OIDC is configured, oidc_login enabled, not in oidc-only mode) */}
{appConfig?.oidc_configured && appConfig?.oidc_login && !oidcOnly && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
+1 -1
View File
@@ -31,7 +31,7 @@ services:
# - OIDC_CLIENT_ID=trek # OpenID Connect client ID
# - OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
# - OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
# - OIDC_ONLY=false # Set true to disable local password auth entirely (SSO only)
# - OIDC_ONLY=false # Set true to force SSO-only mode: disables password login and registration, overrides Admin > Settings toggles, cannot be changed at runtime
# - OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
# - OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
# - OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes as needed (e.g. add groups if using OIDC_ADMIN_CLAIM)
+1 -1
View File
@@ -20,7 +20,7 @@ OIDC_ISSUER=https://auth.example.com # OpenID Connect provider URL
OIDC_CLIENT_ID=trek # OpenID Connect client ID
OIDC_CLIENT_SECRET=supersecret # OpenID Connect client secret
OIDC_DISPLAY_NAME=SSO # Label shown on the SSO login button
OIDC_ONLY=true # Disable local password auth entirely (SSO only)
OIDC_ONLY=true # Disable local password auth entirely (SSO only). Equivalent to setting password_login=false and password_registration=false in Admin > Settings.
OIDC_ADMIN_CLAIM=groups # OIDC claim used to identify admin users
OIDC_ADMIN_VALUE=app-trek-admins # Value of the OIDC claim that grants admin role
OIDC_DISCOVERY_URL= # Override the auto-constructed OIDC discovery endpoint. Useful for providers (e.g. Authentik) that expose it at a non-standard path. Example: https://auth.example.com/application/o/trek/.well-known/openid-configuration
+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' };
}
}
@@ -77,6 +77,7 @@ import {
getAppSettings,
validateKeys,
isOidcOnlyMode,
resolveAuthToggles,
setupMfa,
enableMfa,
disableMfa,
@@ -322,6 +323,80 @@ describe('isOidcOnlyMode', () => {
});
});
// ---------------------------------------------------------------------------
// resolveAuthToggles
// ---------------------------------------------------------------------------
describe('resolveAuthToggles', () => {
afterEach(() => {
vi.unstubAllEnvs();
testDb.prepare("DELETE FROM app_settings WHERE key IN ('password_login','password_registration','oidc_login','oidc_registration','oidc_only','allow_registration')").run();
});
it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', () => {
vi.stubEnv('OIDC_ONLY', '');
const t = resolveAuthToggles();
expect(t.password_login).toBe(true);
expect(t.password_registration).toBe(true);
expect(t.oidc_login).toBe(true);
expect(t.oidc_registration).toBe(true);
});
it('AUTH-DB-022b: legacy — OIDC_ONLY=true with OIDC configured disables password_login and password_registration', () => {
vi.stubEnv('OIDC_ONLY', 'true');
vi.stubEnv('OIDC_ISSUER', 'https://sso.example.com');
vi.stubEnv('OIDC_CLIENT_ID', 'trek-client');
const t = resolveAuthToggles();
expect(t.password_login).toBe(false);
expect(t.password_registration).toBe(false);
expect(t.oidc_login).toBe(true);
expect(t.oidc_registration).toBe(true);
});
it('AUTH-DB-022c: legacy — allow_registration=false disables both password and oidc registration', () => {
vi.stubEnv('OIDC_ONLY', '');
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const t = resolveAuthToggles();
expect(t.password_login).toBe(true);
expect(t.password_registration).toBe(false);
expect(t.oidc_login).toBe(true);
expect(t.oidc_registration).toBe(false);
});
it('AUTH-DB-022d: new granular keys take precedence over legacy keys', () => {
vi.stubEnv('OIDC_ONLY', '');
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run();
const t = resolveAuthToggles();
// New key present → use new keys, allow_registration ignored
expect(t.password_registration).toBe(true);
expect(t.oidc_registration).toBe(true); // defaults to true when key not set
});
it('AUTH-DB-022e: OIDC_ONLY env var overrides new granular keys for password toggles', () => {
vi.stubEnv('OIDC_ONLY', 'true');
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'true')").run();
const t = resolveAuthToggles();
// OIDC_ONLY forces password toggles off even when DB says true
expect(t.password_login).toBe(false);
expect(t.password_registration).toBe(false);
});
it('AUTH-DB-022f: individual granular keys can be set independently', () => {
vi.stubEnv('OIDC_ONLY', '');
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_login', 'true')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('password_registration', 'false')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_login', 'true')").run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('oidc_registration', 'false')").run();
const t = resolveAuthToggles();
expect(t.password_login).toBe(true);
expect(t.password_registration).toBe(false);
expect(t.oidc_login).toBe(true);
expect(t.oidc_registration).toBe(false);
});
});
// ---------------------------------------------------------------------------
// setupMfa
// ---------------------------------------------------------------------------
@@ -454,7 +529,7 @@ describe('registerUser — OIDC-only / registration-disabled', () => {
const result = registerUser({ username: 'u', email: 'new@x.com', password: 'Secure123!' });
expect(result.status).toBe(403);
expect(result.error).toMatch(/SSO/i);
expect(result.error).toMatch(/password registration is disabled/i);
});
it('AUTH-DB-034: returns 403 when registration is disabled and no invite', () => {
+1 -1
View File
@@ -49,7 +49,7 @@
<Config Name="OIDC_CLIENT_ID" Target="OIDC_CLIENT_ID" Default="" Mode="" Description="OIDC client ID registered with your identity provider." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="OIDC_CLIENT_SECRET" Target="OIDC_CLIENT_SECRET" Default="" Mode="" Description="OIDC client secret registered with your identity provider." Type="Variable" Display="advanced" Required="false" Mask="true"/>
<Config Name="OIDC_DISPLAY_NAME" Target="OIDC_DISPLAY_NAME" Default="SSO" Mode="" Description="Label shown on the SSO login button." Type="Variable" Display="advanced" Required="false" Mask="false">SSO</Config>
<Config Name="OIDC_ONLY" Target="OIDC_ONLY" Default="false" Mode="" Description="Set to true to disable local password auth entirely (SSO only). First SSO login becomes admin." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="OIDC_ONLY" Target="OIDC_ONLY" Default="false" Mode="" Description="Set to true to force SSO-only mode. Disables password login and password registration — overrides the granular toggles in Admin &gt; Settings and cannot be changed at runtime. First SSO login becomes admin." Type="Variable" Display="advanced" Required="false" Mask="false">false</Config>
<Config Name="OIDC_ADMIN_CLAIM" Target="OIDC_ADMIN_CLAIM" Default="" Mode="" Description="OIDC claim used to identify admin users (e.g. groups)." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="OIDC_ADMIN_VALUE" Target="OIDC_ADMIN_VALUE" Default="" Mode="" Description="Value of the OIDC claim that grants admin role (e.g. app-trek-admins)." Type="Variable" Display="advanced" Required="false" Mask="false"/>
<Config Name="OIDC_SCOPE" Target="OIDC_SCOPE" Default="openid email profile" Mode="" Description="Space-separated OIDC scopes to request. Fully overrides the default — always include openid email profile plus any extra scopes you need (e.g. add groups when using OIDC_ADMIN_CLAIM)." Type="Variable" Display="advanced" Required="false" Mask="false">openid email profile</Config>