From bfd2553d1e47170226a03abde14d939b6be2be10 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Apr 2026 20:21:22 +0200 Subject: [PATCH 1/2] 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 --- README.md | 4 +- charts/trek/values.yaml | 4 +- client/src/i18n/translations/ar.ts | 11 ++ client/src/i18n/translations/br.ts | 11 ++ client/src/i18n/translations/cs.ts | 11 ++ client/src/i18n/translations/de.ts | 11 ++ client/src/i18n/translations/en.ts | 11 ++ client/src/i18n/translations/es.ts | 11 ++ client/src/i18n/translations/fr.ts | 11 ++ client/src/i18n/translations/hu.ts | 11 ++ client/src/i18n/translations/it.ts | 11 ++ client/src/i18n/translations/nl.ts | 11 ++ client/src/i18n/translations/pl.ts | 11 ++ client/src/i18n/translations/ru.ts | 11 ++ client/src/i18n/translations/zh.ts | 11 ++ client/src/i18n/translations/zhTw.ts | 11 ++ client/src/pages/AdminPage.tsx | 131 +++++++++++++----- client/src/pages/LoginPage.tsx | 15 +- docker-compose.yml | 2 +- server/.env.example | 2 +- server/src/db/seeds.ts | 4 + server/src/routes/admin.ts | 7 +- server/src/routes/oidc.ts | 9 ++ server/src/services/adminService.ts | 11 +- server/src/services/authService.ts | 86 +++++++++--- server/src/services/oidcService.ts | 7 +- .../tests/unit/services/authServiceDb.test.ts | 77 +++++++++- unraid-template.xml | 2 +- 28 files changed, 439 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 629ed01d..e25bd68d 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 9063f06b..05e459d6 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -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: "" diff --git a/client/src/i18n/translations/ar.ts b/client/src/i18n/translations/ar.ts index df59b102..2a60a71d 100644 --- a/client/src/i18n/translations/ar.ts +++ b/client/src/i18n/translations/ar.ts @@ -525,6 +525,17 @@ const ar: Record = { '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', diff --git a/client/src/i18n/translations/br.ts b/client/src/i18n/translations/br.ts index 7c9b00a8..8a065251 100644 --- a/client/src/i18n/translations/br.ts +++ b/client/src/i18n/translations/br.ts @@ -489,6 +489,17 @@ const br: Record = { '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', diff --git a/client/src/i18n/translations/cs.ts b/client/src/i18n/translations/cs.ts index 43369867..fd7b925c 100644 --- a/client/src/i18n/translations/cs.ts +++ b/client/src/i18n/translations/cs.ts @@ -489,6 +489,17 @@ const cs: Record = { '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', diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index a6fe1cd9..8219005e 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -493,6 +493,17 @@ const de: Record = { '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', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index c481f899..fcb940b0 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -518,6 +518,17 @@ const en: Record = { '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', diff --git a/client/src/i18n/translations/es.ts b/client/src/i18n/translations/es.ts index 15756839..3cc64874 100644 --- a/client/src/i18n/translations/es.ts +++ b/client/src/i18n/translations/es.ts @@ -487,6 +487,17 @@ const es: Record = { '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', diff --git a/client/src/i18n/translations/fr.ts b/client/src/i18n/translations/fr.ts index b2d9e934..c04b5130 100644 --- a/client/src/i18n/translations/fr.ts +++ b/client/src/i18n/translations/fr.ts @@ -489,6 +489,17 @@ const fr: Record = { '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', diff --git a/client/src/i18n/translations/hu.ts b/client/src/i18n/translations/hu.ts index 9efc6a91..c83a2323 100644 --- a/client/src/i18n/translations/hu.ts +++ b/client/src/i18n/translations/hu.ts @@ -489,6 +489,17 @@ const hu: Record = { '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', diff --git a/client/src/i18n/translations/it.ts b/client/src/i18n/translations/it.ts index 62bca2f9..ddfce2e3 100644 --- a/client/src/i18n/translations/it.ts +++ b/client/src/i18n/translations/it.ts @@ -489,6 +489,17 @@ const it: Record = { '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', diff --git a/client/src/i18n/translations/nl.ts b/client/src/i18n/translations/nl.ts index 79532979..ca1c4519 100644 --- a/client/src/i18n/translations/nl.ts +++ b/client/src/i18n/translations/nl.ts @@ -490,6 +490,17 @@ const nl: Record = { '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', diff --git a/client/src/i18n/translations/pl.ts b/client/src/i18n/translations/pl.ts index f85e28e0..e21972d8 100644 --- a/client/src/i18n/translations/pl.ts +++ b/client/src/i18n/translations/pl.ts @@ -461,6 +461,17 @@ const pl: Record = { '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', diff --git a/client/src/i18n/translations/ru.ts b/client/src/i18n/translations/ru.ts index 1db338aa..073140a2 100644 --- a/client/src/i18n/translations/ru.ts +++ b/client/src/i18n/translations/ru.ts @@ -490,6 +490,17 @@ const ru: Record = { '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-ключи', diff --git a/client/src/i18n/translations/zh.ts b/client/src/i18n/translations/zh.ts index 4a007923..206f0da8 100644 --- a/client/src/i18n/translations/zh.ts +++ b/client/src/i18n/translations/zh.ts @@ -490,6 +490,17 @@ const zh: Record = { '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 密钥', diff --git a/client/src/i18n/translations/zhTw.ts b/client/src/i18n/translations/zhTw.ts index 58a6138d..11a5d017 100644 --- a/client/src/i18n/translations/zhTw.ts +++ b/client/src/i18n/translations/zhTw.ts @@ -515,6 +515,17 @@ const zhTw: Record = { '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 金鑰', diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx index 051ce84c..10d3cae9 100644 --- a/client/src/pages/AdminPage.tsx +++ b/client/src/pages/AdminPage.tsx @@ -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({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', oidc_only: false, discovery_url: '' }) + const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '', discovery_url: '' }) const [savingOidc, setSavingOidc] = useState(false) - // Registration toggle - const [allowRegistration, setAllowRegistration] = useState(true) + // Auth toggles + const [passwordLogin, setPasswordLogin] = useState(true) + const [passwordRegistration, setPasswordRegistration] = useState(true) + const [oidcLogin, setOidcLogin] = useState(true) + const [oidcRegistration, setOidcRegistration] = useState(true) + const [envOverrideOidcOnly, setEnvOverrideOidcOnly] = useState(false) + const [oidcConfigured, setOidcConfigured] = useState(false) const [requireMfa, setRequireMfa] = useState(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' && (
- {/* Registration Toggle */} + {/* Authentication Methods */}
-

{t('admin.allowRegistration')}

+

{t('admin.authMethods')}

-
+
+ {envOverrideOidcOnly && ( +

+ {t('admin.envOverrideHint')} +

+ )} + {/* Password Login */}
-

{t('admin.allowRegistration')}

-

{t('admin.allowRegistrationHint')}

+

{t('admin.passwordLogin')}

+

{t('admin.passwordLoginHint')}

+ {/* Password Registration */} +
+
+

{t('admin.passwordRegistration')}

+

{t('admin.passwordRegistrationHint')}

+
+ +
+ {/* SSO Login (only when OIDC configured) */} + {oidcConfigured && ( +
+
+

{t('admin.oidcLogin')}

+

{t('admin.oidcLoginHint')}

+
+ +
+ )} + {/* SSO Registration (only when OIDC configured) */} + {oidcConfigured && ( +
+
+

{t('admin.oidcRegistration')}

+

{t('admin.oidcRegistrationHint')}

+
+ +
+ )}
@@ -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" />
- {/* OIDC-only mode toggle */} -
-
-

{t('admin.oidcOnlyMode')}

-

{t('admin.oidcOnlyModeHint')}

-
- -
-
- {/* 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 && ( <>
diff --git a/docker-compose.yml b/docker-compose.yml index 2bc0634b..946c133d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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) diff --git a/server/.env.example b/server/.env.example index d3943132..932a274f 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts index 2f6ee3ea..5cc557ae 100644 --- a/server/src/db/seeds.ts +++ b/server/src/db/seeds.ts @@ -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); diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 2579dc2d..5184d4c6 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -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 }); }); diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts index dc018270..f35be382 100644 --- a/server/src/routes/oidc.ts +++ b/server/src/routes/oidc.ts @@ -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) { diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index 7566e240..41a595c9 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -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 ────────────────────────────────────────────────────────── diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index 5be0e45b..4a05c8ff 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -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]); diff --git a/server/src/services/oidcService.ts b/server/src/services/oidcService.ts index 7ca46c67..e5c8c683 100644 --- a/server/src/services/oidcService.ts +++ b/server/src/services/oidcService.ts @@ -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' }; } } diff --git a/server/tests/unit/services/authServiceDb.test.ts b/server/tests/unit/services/authServiceDb.test.ts index 8e2ac9ca..c4726049 100644 --- a/server/tests/unit/services/authServiceDb.test.ts +++ b/server/tests/unit/services/authServiceDb.test.ts @@ -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', () => { diff --git a/unraid-template.xml b/unraid-template.xml index e62f632e..fa3f2fc6 100644 --- a/unraid-template.xml +++ b/unraid-template.xml @@ -49,7 +49,7 @@ SSO - false + false openid email profile From 47d9cce936a767c1c6f33d945f0b45cebb733850 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Apr 2026 20:30:30 +0200 Subject: [PATCH 2/2] fix(tests): update tests for granular auth toggles - Add new fields to AppConfig type and buildAppConfig factory - Update FE-PAGE-ADMIN-018: heading changed to "Authentication Methods" - Update FE-PAGE-ADMIN-053: oidc_only toggle removed from OIDC panel - Update FE-PAGE-LOGIN-007/017: mocks now include password_login/oidc_login - Update ADMIN-SVC-049: updateOidcSettings no longer writes oidc_only --- client/src/pages/AdminPage.test.tsx | 17 ++++------------- client/src/pages/LoginPage.test.tsx | 5 +++++ client/src/types.ts | 7 +++++++ client/tests/helpers/factories.ts | 6 ++++++ server/tests/unit/services/adminService.test.ts | 13 +++++-------- 5 files changed, 27 insertions(+), 21 deletions(-) diff --git a/client/src/pages/AdminPage.test.tsx b/client/src/pages/AdminPage.test.tsx index dc0aa6ed..d3f851af 100644 --- a/client/src/pages/AdminPage.test.tsx +++ b/client/src/pages/AdminPage.test.tsx @@ -359,13 +359,13 @@ describe('AdminPage', () => { fireEvent.click(screen.getByRole('button', { name: /settings/i })); - const heading = await screen.findByRole('heading', { name: /allow registration/i }); + const heading = await screen.findByRole('heading', { name: /authentication methods/i }); const card = heading.closest('.bg-white'); - const toggle = within(card!).getByRole('button'); - fireEvent.click(toggle); + const toggles = within(card!).getAllByRole('button'); + fireEvent.click(toggles[0]); // First toggle = password_login await waitFor(() => { - expect(capturedBody).toEqual(expect.objectContaining({ allow_registration: false })); + expect(capturedBody).toEqual(expect.objectContaining({ password_login: false })); }); }); }); @@ -1328,15 +1328,6 @@ describe('AdminPage', () => { const clientSecretInput = clientSecretLabel.closest('div')!.querySelector('input')!; fireEvent.change(clientSecretInput, { target: { value: 'my-client-secret' } }); - // OIDC-only toggle — button within the OIDC card for oidc_only toggle - // admin.oidcOnlyMode = 'Disable password authentication' - const oidcOnlyText = within(oidcCard!).getByText('Disable password authentication'); - const oidcOnlySection = oidcOnlyText.closest('.flex'); - const oidcOnlyToggle = oidcOnlySection?.querySelector('button'); - if (oidcOnlyToggle) { - fireEvent.click(oidcOnlyToggle); - } - // Verify the inputs updated expect((issuerInput as HTMLInputElement).value).toBe('https://accounts.google.com'); expect((clientIdInput as HTMLInputElement).value).toBe('my-client-id'); diff --git a/client/src/pages/LoginPage.test.tsx b/client/src/pages/LoginPage.test.tsx index e50dc200..5f5adc87 100644 --- a/client/src/pages/LoginPage.test.tsx +++ b/client/src/pages/LoginPage.test.tsx @@ -155,6 +155,9 @@ describe('LoginPage', () => { oidc_configured: true, oidc_display_name: 'Okta', oidc_only_mode: false, + oidc_login: true, + password_login: true, + password_registration: true, setup_complete: true, }); }), @@ -438,6 +441,8 @@ describe('LoginPage', () => { demo_mode: false, oidc_configured: true, oidc_only_mode: true, + password_login: false, + oidc_login: true, setup_complete: true, }); }), diff --git a/client/src/types.ts b/client/src/types.ts index 18938a45..159a0edd 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -296,11 +296,18 @@ export interface AppConfig { demo_mode: boolean oidc_configured: boolean oidc_display_name?: string + oidc_only_mode?: boolean has_maps_key?: boolean allowed_file_types?: string timezone?: string /** When true, users without MFA cannot use the app until they enable it */ require_mfa?: boolean + // Granular auth toggles + password_login?: boolean + password_registration?: boolean + oidc_login?: boolean + oidc_registration?: boolean + env_override_oidc_only?: boolean } // Translation function type diff --git a/client/tests/helpers/factories.ts b/client/tests/helpers/factories.ts index 27d07f90..8bc8b468 100644 --- a/client/tests/helpers/factories.ts +++ b/client/tests/helpers/factories.ts @@ -283,6 +283,12 @@ export function buildAppConfig(overrides: Partial = {}): AppConfig { allow_registration: true, demo_mode: false, oidc_configured: false, + oidc_only_mode: false, + password_login: true, + password_registration: true, + oidc_login: true, + oidc_registration: true, + env_override_oidc_only: false, ...overrides, }; } diff --git a/server/tests/unit/services/adminService.test.ts b/server/tests/unit/services/adminService.test.ts index 746bf48a..7a0e09ef 100644 --- a/server/tests/unit/services/adminService.test.ts +++ b/server/tests/unit/services/adminService.test.ts @@ -471,14 +471,11 @@ describe('OIDC Settings', () => { expect(result.client_id).toBe('my-client'); }); - it('ADMIN-SVC-049 — updateOidcSettings sets oidc_only flag correctly', () => { - updateOidcSettings({ oidc_only: true }); - const enabled = getOidcSettings() as any; - expect(enabled.oidc_only).toBe(true); - - updateOidcSettings({ oidc_only: false }); - const disabled = getOidcSettings() as any; - expect(disabled.oidc_only).toBe(false); + it('ADMIN-SVC-049 — updateOidcSettings does not write oidc_only (replaced by granular toggles)', () => { + updateOidcSettings({ issuer: 'https://auth.example.com', client_id: 'my-client' }); + const result = getOidcSettings() as any; + // oidc_only is no longer managed by updateOidcSettings; use password_login/oidc_login toggles + expect(result.oidc_only).toBe(false); }); });