mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 05:11:46 +00:00
feat(auth): split OIDC_ONLY into granular auth toggles
Replaces the coarse oidc_only + allow_registration settings with four independent toggles: password_login, password_registration, oidc_login, oidc_registration. Each can be enabled/disabled individually in Admin > Settings without affecting the others. - Add resolveAuthToggles() in authService.ts as the central resolver; falls back to legacy oidc_only/allow_registration keys when new keys are absent (backward compat) - OIDC_ONLY env var still works and overrides DB toggles for password_*, with a visual lock in the admin UI when active - Server enforces lockout prevention: cannot disable all login methods - oidc_login gate added to OIDC /login and /callback routes - Remove oidc_only toggle from OIDC settings panel; replaced by the granular toggles in the Settings tab - Add 6 new resolveAuthToggles() unit tests; fix AUTH-DB-033 error message assertion - Update OIDC_ONLY descriptions in README, docker-compose, Helm values, Unraid template, and .env.example to clarify override semantics Closes #492
This commit is contained in:
@@ -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` |
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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-ключи',
|
||||
|
||||
@@ -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 密钥',
|
||||
|
||||
@@ -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 金鑰',
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import crypto from 'crypto';
|
||||
|
||||
// Seeds run at startup before the DB admin panel can be used, so only env vars
|
||||
// are checked here. The granular password_login/password_registration DB toggles
|
||||
// are only relevant after the first user exists; at that point seeds have already
|
||||
// finished and skip via the userCount > 0 guard above.
|
||||
function isOidcOnlyConfigured(): boolean {
|
||||
if (process.env.OIDC_ONLY !== 'true') return false;
|
||||
return !!(process.env.OIDC_ISSUER && process.env.OIDC_CLIENT_ID);
|
||||
|
||||
@@ -102,13 +102,16 @@ router.get('/oidc', (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
router.put('/oidc', (req: Request, res: Response) => {
|
||||
svc.updateOidcSettings(req.body);
|
||||
const result = svc.updateOidcSettings(req.body);
|
||||
if (result.error) {
|
||||
return res.status(result.status || 400).json({ error: result.error });
|
||||
}
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.oidc_update',
|
||||
ip: getClientIp(req),
|
||||
details: { oidc_only: !!req.body.oidc_only, issuer_set: !!req.body.issuer },
|
||||
details: { issuer_set: !!req.body.issuer },
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
@@ -15,12 +15,17 @@ import {
|
||||
frontendUrl,
|
||||
getAppUrl,
|
||||
} from '../services/oidcService';
|
||||
import { resolveAuthToggles } from '../services/authService';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ---- GET /login ----------------------------------------------------------
|
||||
|
||||
router.get('/login', async (req: Request, res: Response) => {
|
||||
if (!resolveAuthToggles().oidc_login) {
|
||||
return res.status(403).json({ error: 'SSO login is disabled.' });
|
||||
}
|
||||
|
||||
const config = getOidcConfig();
|
||||
if (!config) return res.status(400).json({ error: 'OIDC not configured' });
|
||||
|
||||
@@ -57,6 +62,10 @@ router.get('/login', async (req: Request, res: Response) => {
|
||||
// ---- GET /callback -------------------------------------------------------
|
||||
|
||||
router.get('/callback', async (req: Request, res: Response) => {
|
||||
if (!resolveAuthToggles().oidc_login) {
|
||||
return res.redirect(frontendUrl('/login?oidc_error=sso_disabled'));
|
||||
}
|
||||
|
||||
const { code, state, error: oidcError } = req.query as { code?: string; state?: string; error?: string };
|
||||
|
||||
if (oidcError) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { revokeUserSessions, revokeUserSessionsForClient } from '../mcp';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { getPhotoProviderConfig } from './memories/helpersService';
|
||||
import { send as sendNotification } from './notificationService';
|
||||
import { resolveAuthToggles } from './authService';
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -254,16 +255,20 @@ export function updateOidcSettings(data: {
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
display_name?: string;
|
||||
oidc_only?: boolean;
|
||||
discovery_url?: string;
|
||||
}) {
|
||||
}): { error?: string; status?: number; success?: boolean } {
|
||||
// Lockout prevention: can't remove OIDC config when password login is disabled
|
||||
if ((data.issuer === '' || data.client_id === '') && !resolveAuthToggles().password_login) {
|
||||
return { error: 'Cannot remove SSO configuration while password login is disabled. Enable password login first.', status: 400 };
|
||||
}
|
||||
|
||||
const set = (key: string, val: string) => db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)").run(key, val || '');
|
||||
set('oidc_issuer', data.issuer ?? '');
|
||||
set('oidc_client_id', data.client_id ?? '');
|
||||
if (data.client_secret !== undefined) set('oidc_client_secret', maybe_encrypt_api_key(data.client_secret) ?? '');
|
||||
set('oidc_display_name', data.display_name ?? '');
|
||||
set('oidc_only', data.oidc_only ? 'true' : 'false');
|
||||
set('oidc_discovery_url', data.discovery_url ?? '');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// ── Demo Baseline ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -31,6 +31,7 @@ const ADMIN_SETTINGS_KEYS = [
|
||||
'allow_registration', 'allowed_file_types', 'require_mfa',
|
||||
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_skip_tls_verify',
|
||||
'notification_channels', 'admin_webhook_url',
|
||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||
];
|
||||
|
||||
const avatarDir = path.join(__dirname, '../../uploads/avatars');
|
||||
@@ -107,16 +108,51 @@ export function avatarUrl(user: { avatar?: string | null }): string | null {
|
||||
return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
|
||||
}
|
||||
|
||||
export function isOidcOnlyMode(): boolean {
|
||||
export function resolveAuthToggles(): {
|
||||
password_login: boolean;
|
||||
password_registration: boolean;
|
||||
oidc_login: boolean;
|
||||
oidc_registration: boolean;
|
||||
} {
|
||||
const get = (key: string) =>
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
|
||||
const enabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
|
||||
if (!enabled) return false;
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value ?? null;
|
||||
|
||||
const hasNewKeys = ['password_login', 'password_registration', 'oidc_login', 'oidc_registration']
|
||||
.some(k => get(k) !== null);
|
||||
|
||||
if (hasNewKeys) {
|
||||
const result = {
|
||||
password_login: get('password_login') !== 'false',
|
||||
password_registration: get('password_registration') !== 'false',
|
||||
oidc_login: get('oidc_login') !== 'false',
|
||||
oidc_registration: get('oidc_registration') !== 'false',
|
||||
};
|
||||
if (process.env.OIDC_ONLY === 'true') {
|
||||
result.password_login = false;
|
||||
result.password_registration = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Legacy fallback
|
||||
const oidcOnlyEnabled = process.env.OIDC_ONLY === 'true' || get('oidc_only') === 'true';
|
||||
const oidcConfigured = !!(
|
||||
(process.env.OIDC_ISSUER || get('oidc_issuer')) &&
|
||||
(process.env.OIDC_CLIENT_ID || get('oidc_client_id'))
|
||||
);
|
||||
return oidcConfigured;
|
||||
const oidcOnly = oidcOnlyEnabled && oidcConfigured;
|
||||
const allowReg = (get('allow_registration') ?? 'true') === 'true';
|
||||
|
||||
return {
|
||||
password_login: !oidcOnly,
|
||||
password_registration: !oidcOnly && allowReg,
|
||||
oidc_login: true,
|
||||
oidc_registration: allowReg,
|
||||
};
|
||||
}
|
||||
|
||||
export function isOidcOnlyMode(): boolean {
|
||||
return !resolveAuthToggles().password_login;
|
||||
}
|
||||
|
||||
export function generateToken(user: { id: number | bigint }) {
|
||||
@@ -174,9 +210,8 @@ export function getPendingMfaSecret(userId: number): string | null {
|
||||
|
||||
export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
||||
const allowRegistration = userCount === 0 || (setting?.value ?? 'true') === 'true';
|
||||
const isDemo = process.env.DEMO_MODE === 'true';
|
||||
const toggles = resolveAuthToggles();
|
||||
const { version } = require('../../package.json');
|
||||
const hasGoogleKey = !!db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get();
|
||||
const oidcDisplayName = process.env.OIDC_DISPLAY_NAME ||
|
||||
@@ -185,9 +220,6 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
|
||||
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
|
||||
);
|
||||
const oidcOnlySetting = process.env.OIDC_ONLY ||
|
||||
(db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_only'").get() as { value: string } | undefined)?.value;
|
||||
const oidcOnlyMode = oidcConfigured && oidcOnlySetting === 'true';
|
||||
const requireMfaRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||
const notifChannel = (db.prepare("SELECT value FROM app_settings WHERE key = 'notification_channel'").get() as { value: string } | undefined)?.value || 'none';
|
||||
const tripReminderSetting = (db.prepare("SELECT value FROM app_settings WHERE key = 'notify_trip_reminder'").get() as { value: string } | undefined)?.value;
|
||||
@@ -200,14 +232,21 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
const setupComplete = userCount > 0 && !(db.prepare("SELECT id FROM users WHERE role = 'admin' AND must_change_password = 1 LIMIT 1").get());
|
||||
|
||||
return {
|
||||
allow_registration: isDemo ? false : allowRegistration,
|
||||
// Legacy fields (backward compat)
|
||||
allow_registration: isDemo ? false : (toggles.password_registration || toggles.oidc_registration),
|
||||
oidc_only_mode: !toggles.password_login && !toggles.password_registration,
|
||||
// Granular toggles
|
||||
password_login: toggles.password_login,
|
||||
password_registration: isDemo ? false : toggles.password_registration,
|
||||
oidc_login: toggles.oidc_login,
|
||||
oidc_registration: isDemo ? false : toggles.oidc_registration,
|
||||
env_override_oidc_only: process.env.OIDC_ONLY === 'true',
|
||||
has_users: userCount > 0,
|
||||
setup_complete: setupComplete,
|
||||
version,
|
||||
has_maps_key: hasGoogleKey,
|
||||
oidc_configured: oidcConfigured,
|
||||
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
||||
oidc_only_mode: oidcOnlyMode,
|
||||
require_mfa: requireMfaRow?.value === 'true',
|
||||
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
|
||||
demo_mode: isDemo,
|
||||
@@ -265,12 +304,9 @@ export function registerUser(body: {
|
||||
}
|
||||
|
||||
if (userCount > 0 && !validInvite) {
|
||||
if (isOidcOnlyMode()) {
|
||||
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
|
||||
}
|
||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as { value: string } | undefined;
|
||||
if (setting?.value === 'false') {
|
||||
return { error: 'Registration is disabled. Contact your administrator.', status: 403 };
|
||||
const toggles = resolveAuthToggles();
|
||||
if (!toggles.password_registration) {
|
||||
return { error: 'Password registration is disabled. Contact your administrator.', status: 403 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,6 +743,20 @@ export function updateAppSettings(
|
||||
}
|
||||
}
|
||||
|
||||
// Lockout prevention: can't disable all login methods
|
||||
if (body.password_login !== undefined || body.oidc_login !== undefined) {
|
||||
const current = resolveAuthToggles();
|
||||
const oidcConfigured = !!(
|
||||
(process.env.OIDC_ISSUER || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_issuer'").get() as { value: string } | undefined)?.value) &&
|
||||
(process.env.OIDC_CLIENT_ID || (db.prepare("SELECT value FROM app_settings WHERE key = 'oidc_client_id'").get() as { value: string } | undefined)?.value)
|
||||
);
|
||||
const nextPasswordLogin = body.password_login !== undefined ? (String(body.password_login) === 'true') : current.password_login;
|
||||
const nextOidcLogin = body.oidc_login !== undefined ? (String(body.oidc_login) === 'true') : current.oidc_login;
|
||||
if (!nextPasswordLogin && (!nextOidcLogin || !oidcConfigured)) {
|
||||
return { error: 'Cannot disable all login methods. At least one must remain enabled.', status: 400 };
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of ADMIN_SETTINGS_KEYS) {
|
||||
if (body[key] !== undefined) {
|
||||
let val = String(body[key]);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { db } from '../db/database';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { User } from '../types';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { resolveAuthToggles } from './authService';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -269,10 +270,8 @@ export function findOrCreateUser(
|
||||
}
|
||||
|
||||
if (!isFirstUser && !validInvite) {
|
||||
const setting = db.prepare("SELECT value FROM app_settings WHERE key = 'allow_registration'").get() as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
if (setting?.value === 'false') {
|
||||
const { oidc_registration } = resolveAuthToggles();
|
||||
if (!oidc_registration) {
|
||||
return { error: 'registration_disabled' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 > 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>
|
||||
|
||||
Reference in New Issue
Block a user