From 33bee70a126d8aa77628fbe4684cd8af2b2f8e09 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Thu, 5 Feb 2026 17:54:59 +0100 Subject: [PATCH 01/12] Revamp OIDC auth: remove Passport wrapper, add schema-driven settings UI - Remove Passport.js wrapper from OIDC auth, use openid-client directly - Add schema-driven OIDC settings UI (OidcSettingsSchema.js drives form rendering) - Add group mapping with KeyValueEditor (explicit mapping or legacy direct name match) - Add scopes configuration (authOpenIDScopes) - Add verified email enforcement option (authOpenIDRequireVerifiedEmail) - Fix group claim validation rejecting URN-style claims (#4744) - Add auto-discover endpoint for OIDC provider configuration - Store oidcIdToken in sessions table instead of cookie - Add AuthError class for structured error handling in auth flows - Migration v2.33.0 adds oidcIdToken column and new settings fields --- client/components/app/KeyValueEditor.vue | 79 ++++ client/components/app/OidcSettings.vue | 102 ++++++ client/pages/config/authentication.vue | 331 ++++------------- server/Auth.js | 239 +++++------- server/auth/AuthError.js | 9 + server/auth/OidcAuthStrategy.js | 209 ++++++----- server/auth/OidcSettingsSchema.js | 340 ++++++++++++++++++ server/auth/TokenManager.js | 16 +- server/controllers/MiscController.js | 178 ++++++--- .../v2.33.0-oidc-scopes-and-group-map.js | 143 ++++++++ server/models/Session.js | 9 +- server/objects/settings/ServerSettings.js | 44 ++- server/routers/ApiRouter.js | 1 + test/server/auth/AuthError.test.js | 24 ++ test/server/auth/OidcAuthStrategy.test.js | 246 +++++++++++++ test/server/auth/OidcSettingsSchema.test.js | 155 ++++++++ 16 files changed, 1554 insertions(+), 571 deletions(-) create mode 100644 client/components/app/KeyValueEditor.vue create mode 100644 client/components/app/OidcSettings.vue create mode 100644 server/auth/AuthError.js create mode 100644 server/auth/OidcSettingsSchema.js create mode 100644 server/migrations/v2.33.0-oidc-scopes-and-group-map.js create mode 100644 test/server/auth/AuthError.test.js create mode 100644 test/server/auth/OidcAuthStrategy.test.js create mode 100644 test/server/auth/OidcSettingsSchema.test.js diff --git a/client/components/app/KeyValueEditor.vue b/client/components/app/KeyValueEditor.vue new file mode 100644 index 000000000..a50bfba4b --- /dev/null +++ b/client/components/app/KeyValueEditor.vue @@ -0,0 +1,79 @@ + + + diff --git a/client/components/app/OidcSettings.vue b/client/components/app/OidcSettings.vue new file mode 100644 index 000000000..5ca3a8da2 --- /dev/null +++ b/client/components/app/OidcSettings.vue @@ -0,0 +1,102 @@ + + + diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index f31f9ea22..ff5a41004 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -31,99 +31,12 @@ -
-
-
- -
-
- - auto_fix_high - Auto-populate -
-
- - - - - - - - - - - - - - - - - - - -

- -

-
- -
-
-

{{ $strings.LabelWebRedirectURLsDescription }}

-

- {{ webCallbackURL }} -
- {{ mobileAppCallbackURL }} -

-
-
- - - -
-
- -
-

{{ $strings.LabelMatchExistingUsersByDescription }}

-
- -
- -

{{ $strings.LabelAutoLaunch }}

-

-

- -
- -

{{ $strings.LabelAutoRegister }}

-

{{ $strings.LabelAutoRegisterDescription }}

-
- -

{{ $strings.LabelOpenIDClaims }}

- -
-
- -
-

-
- -
-
- -
-
-

-
{{ newAuthSettings.authOpenIDSamplePermissions }}
-                
-
-
+
+
-
-

{{ $strings.MessageAuthenticationOIDCChangesRestart }}

+
{{ $strings.ButtonSave }}
@@ -156,171 +69,68 @@ export default { enableOpenIDAuth: false, showCustomLoginMessage: false, savingSettings: false, - openIdSigningAlgorithmsSupportedByIssuer: [], - newAuthSettings: {} + openIDSchemaOverrides: {}, + newAuthSettings: {}, + openIDValues: {} } }, computed: { authMethods() { return this.authSettings.authActiveAuthMethods || [] }, - matchingExistingOptions() { - return [ - { - text: 'Do not match', - value: null - }, - { - text: 'Match by email', - value: 'email' - }, - { - text: 'Match by username', - value: 'username' - } - ] + openIDSchema() { + return this.authSettings.openIDSettings?.schema || [] }, - subfolderOptions() { - const options = [ - { - text: 'None', - value: '' - } - ] - if (this.$config.routerBasePath) { - options.push({ - text: this.$config.routerBasePath, - value: this.$config.routerBasePath - }) - } - return options - }, - webCallbackURL() { - return `https://${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback` - }, - mobileAppCallbackURL() { - return `https://${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect` + openIDGroups() { + return this.authSettings.openIDSettings?.groups || [] } }, methods: { - autoPopulateOIDCClick() { - if (!this.newAuthSettings.authOpenIDIssuerURL) { + onOidcSettingChange({ key, value }) { + this.$set(this.openIDValues, key, value) + }, + onOidcAction(action) { + if (action === 'discover') { + this.discoverOIDC() + } + }, + async discoverOIDC() { + let issuerUrl = this.openIDValues.authOpenIDIssuerURL + if (!issuerUrl) { this.$toast.error('Issuer URL required') return } + // Remove trailing slash - let issuerUrl = this.newAuthSettings.authOpenIDIssuerURL if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) // If the full config path is on the issuer url then remove it if (issuerUrl.endsWith('/.well-known/openid-configuration')) { issuerUrl = issuerUrl.replace('/.well-known/openid-configuration', '') - this.newAuthSettings.authOpenIDIssuerURL = this.newAuthSettings.authOpenIDIssuerURL.replace('/.well-known/openid-configuration', '') + this.$set(this.openIDValues, 'authOpenIDIssuerURL', issuerUrl) } - const setSupportedSigningAlgorithms = (algorithms) => { - if (!algorithms?.length || !Array.isArray(algorithms)) { - console.warn('Invalid id_token_signing_alg_values_supported from openid-configuration', algorithms) - this.openIdSigningAlgorithmsSupportedByIssuer = [] - return - } - this.openIdSigningAlgorithmsSupportedByIssuer = algorithms + try { + const data = await this.$axios.$post('/api/auth-settings/openid/discover', { issuerUrl }) - // If a signing algorithm is already selected, then keep it, when it is still supported. - // But if it is not supported, then select one of the supported ones. - let currentAlgorithm = this.newAuthSettings.authOpenIDTokenSigningAlgorithm - if (!algorithms.includes(currentAlgorithm)) { - this.newAuthSettings.authOpenIDTokenSigningAlgorithm = algorithms[0] - } - } - - this.$axios - .$get(`/auth/openid/config?issuer=${issuerUrl}`) - .then((data) => { - if (data.issuer) this.newAuthSettings.authOpenIDIssuerURL = data.issuer - if (data.authorization_endpoint) this.newAuthSettings.authOpenIDAuthorizationURL = data.authorization_endpoint - if (data.token_endpoint) this.newAuthSettings.authOpenIDTokenURL = data.token_endpoint - if (data.userinfo_endpoint) this.newAuthSettings.authOpenIDUserInfoURL = data.userinfo_endpoint - if (data.end_session_endpoint) this.newAuthSettings.authOpenIDLogoutURL = data.end_session_endpoint - if (data.jwks_uri) this.newAuthSettings.authOpenIDJwksURL = data.jwks_uri - if (data.id_token_signing_alg_values_supported) setSupportedSigningAlgorithms(data.id_token_signing_alg_values_supported) - }) - .catch((error) => { - console.error('Failed to receive data', error) - const errorMsg = error.response?.data || 'Unknown error' - this.$toast.error(errorMsg) - }) - }, - validateOpenID() { - let isValid = true - if (!this.newAuthSettings.authOpenIDIssuerURL) { - this.$toast.error('Issuer URL required') - isValid = false - } - if (!this.newAuthSettings.authOpenIDAuthorizationURL) { - this.$toast.error('Authorize URL required') - isValid = false - } - if (!this.newAuthSettings.authOpenIDTokenURL) { - this.$toast.error('Token URL required') - isValid = false - } - if (!this.newAuthSettings.authOpenIDUserInfoURL) { - this.$toast.error('Userinfo URL required') - isValid = false - } - if (!this.newAuthSettings.authOpenIDJwksURL) { - this.$toast.error('JWKS URL required') - isValid = false - } - if (!this.newAuthSettings.authOpenIDClientID) { - this.$toast.error('Client ID required') - isValid = false - } - if (!this.newAuthSettings.authOpenIDClientSecret) { - this.$toast.error('Client Secret required') - isValid = false - } - if (!this.newAuthSettings.authOpenIDTokenSigningAlgorithm) { - this.$toast.error('Signing Algorithm required') - isValid = false - } - - function isValidRedirectURI(uri) { - // Check for somestring://someother/string - const pattern = new RegExp('^\\w+://[\\w\\.-]+(/[\\w\\./-]*)*$', 'i') - return pattern.test(uri) - } - - const uris = this.newAuthSettings.authOpenIDMobileRedirectURIs - if (uris.includes('*') && uris.length > 1) { - this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used') - isValid = false - } else { - uris.forEach((uri) => { - if (uri !== '*' && !isValidRedirectURI(uri)) { - this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`) - isValid = false + // Apply discovered values + if (data.values) { + for (const [key, value] of Object.entries(data.values)) { + if (value !== null && value !== undefined) { + this.$set(this.openIDValues, key, value) + } } - }) - } + } - function isValidClaim(claim) { - if (claim === '') return true - - const pattern = new RegExp('^[a-zA-Z][a-zA-Z0-9_-]*$', 'i') - return pattern.test(claim) + // Apply schema overrides (e.g., supported signing algorithms) + if (data.schemaOverrides) { + this.openIDSchemaOverrides = data.schemaOverrides + } + } catch (error) { + console.error('Failed to discover OIDC config', error) + const errorMsg = error.response?.data?.error || error.response?.data || 'Unknown error' + this.$toast.error(errorMsg) } - if (!isValidClaim(this.newAuthSettings.authOpenIDGroupClaim)) { - this.$toast.error('Group Claim: Invalid claim name') - isValid = false - } - if (!isValidClaim(this.newAuthSettings.authOpenIDAdvancedPermsClaim)) { - this.$toast.error('Advanced Permission Claim: Invalid claim name') - isValid = false - } - - return isValid }, async saveSettings() { if (!this.enableLocalAuth && !this.enableOpenIDAuth) { @@ -328,42 +138,53 @@ export default { return } - if (this.enableOpenIDAuth && !this.validateOpenID()) { - return - } - if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) { this.newAuthSettings.authLoginCustomMessage = null } - this.newAuthSettings.authActiveAuthMethods = [] - if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local') - if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid') + const authActiveAuthMethods = [] + if (this.enableLocalAuth) authActiveAuthMethods.push('local') + if (this.enableOpenIDAuth) authActiveAuthMethods.push('openid') + + const payload = { + authLoginCustomMessage: this.newAuthSettings.authLoginCustomMessage, + authActiveAuthMethods, + openIDSettings: this.openIDValues + } this.savingSettings = true - this.$axios - .$patch('/api/auth-settings', this.newAuthSettings) - .then((data) => { - this.$store.commit('setServerSettings', data.serverSettings) - if (data.updated) { - this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess) - } else { - this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) - } - }) - .catch((error) => { - console.error('Failed to update server settings', error) + try { + const data = await this.$axios.$patch('/api/auth-settings', payload) + this.$store.commit('setServerSettings', data.serverSettings) + if (data.updated) { + this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess) + } else { + this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary) + } + } catch (error) { + console.error('Failed to update server settings', error) + if (error.response?.data?.details) { + error.response.data.details.forEach((detail) => this.$toast.error(detail)) + } else { this.$toast.error(this.$strings.ToastFailedToUpdate) - }) - .finally(() => { - this.savingSettings = false - }) + } + } finally { + this.savingSettings = false + } }, init() { this.newAuthSettings = { - ...this.authSettings, - authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs + authLoginCustomMessage: this.authSettings.authLoginCustomMessage, + authActiveAuthMethods: this.authSettings.authActiveAuthMethods } + + // Initialize OIDC values from server response + const serverValues = this.authSettings.openIDSettings?.values || {} + this.openIDValues = { + ...serverValues, + authOpenIDSubfolderForRedirectURLs: serverValues.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : serverValues.authOpenIDSubfolderForRedirectURLs + } + this.enableLocalAuth = this.authMethods.includes('local') this.enableOpenIDAuth = this.authMethods.includes('openid') this.showCustomLoginMessage = !!this.authSettings.authLoginCustomMessage diff --git a/server/Auth.js b/server/Auth.js index f63e84460..941f41e6b 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -107,6 +107,7 @@ class Auth { // #region Passport strategies /** * Inializes all passportjs strategies and other passportjs ralated initialization. + * Note: OIDC no longer uses passport - only local auth and JWT use it. */ async initPassportJs() { // Check if we should load the local strategy (username + password login) @@ -114,10 +115,7 @@ class Auth { this.localAuthStrategy.init() } - // Check if we should load the openid strategy - if (global.ServerSettings.authActiveAuthMethods.includes('openid')) { - this.oidcAuthStrategy.init() - } + // OIDC no longer needs passport initialization - it handles tokens directly // Load the JwtStrategy (always) -> for bearer token auth passport.use( @@ -168,7 +166,7 @@ class Auth { */ unuseAuthStrategy(name) { if (name === 'openid') { - this.oidcAuthStrategy.unuse() + this.oidcAuthStrategy.reload() } else if (name === 'local') { this.localAuthStrategy.unuse() } else { @@ -183,7 +181,7 @@ class Auth { */ useAuthStrategy(name) { if (name === 'openid') { - this.oidcAuthStrategy.init() + this.oidcAuthStrategy.reload() } else if (name === 'local') { this.localAuthStrategy.init() } else { @@ -202,84 +200,7 @@ class Auth { } /** - * Stores the client's choice of login callback method in temporary cookies. - * - * The `authMethod` parameter specifies the authentication strategy and can have the following values: - * - 'local': Standard authentication, - * - 'api': Authentication for API use - * - 'openid': OpenID authentication directly over web - * - 'openid-mobile': OpenID authentication, but done via an mobile device - * - * @param {Request} req - * @param {Response} res - * @param {string} authMethod - The authentication method, default is 'local'. - * @returns {Object|null} - Returns error object if validation fails, null if successful - */ - paramsToCookies(req, res, authMethod = 'local') { - const TWO_MINUTES = 120000 // 2 minutes in milliseconds - const callback = req.query.redirect_uri || req.query.callback - - // Additional handling for non-API based authMethod - if (!this.isAuthMethodAPIBased(authMethod)) { - // Store 'auth_state' if present in the request - if (req.query.state) { - res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true }) - } - - // Validate and store the callback URL - if (!callback) { - res.status(400).send({ message: 'No callback parameter' }) - return { error: 'No callback parameter' } - } - - // Security: Validate callback URL is same-origin only - if (!this.oidcAuthStrategy.isValidWebCallbackUrl(callback, req)) { - Logger.warn(`[Auth] Rejected invalid callback URL: ${callback}`) - res.status(400).send({ message: 'Invalid callback URL - must be same-origin' }) - return { error: 'Invalid callback URL - must be same-origin' } - } - - res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true }) - } - - // Store the authentication method for long - Logger.debug(`[Auth] paramsToCookies: setting auth_method cookie to ${authMethod}`) - res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true }) - return null - } - - /** - * Informs the client in the right mode about a successfull login and the token - * (clients choise is restored from cookies). - * - * @param {Request} req - * @param {Response} res - */ - async handleLoginSuccessBasedOnCookie(req, res) { - // Handle token generation and get userResponse object - // For API based auth (e.g. mobile), we will return the refresh token in the response - const isApiBased = this.isAuthMethodAPIBased(req.cookies.auth_method) - Logger.debug(`[Auth] handleLoginSuccessBasedOnCookie: isApiBased: ${isApiBased}, auth_method: ${req.cookies.auth_method}`) - const userResponse = await this.handleLoginSuccess(req, res, isApiBased) - - if (isApiBased) { - // REST request - send data - res.json(userResponse) - } else { - // UI request -> check if we have a callback url - if (req.cookies.auth_cb) { - let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' - // UI request -> redirect to auth_cb url and send the jwt token as parameter - // TODO: Temporarily continue sending the old token as setToken - res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`) - } else { - res.status(400).send('No callback or already expired') - } - } - } - - /** - * After login success from local or oidc + * After login success from local auth * req.user is set by passport.authenticate * * attaches the access token to the user in the response @@ -318,6 +239,9 @@ class Auth { async initAuthRoutes(router) { // Local strategy login route (takes username and password) router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => { + // Clear auth_method cookie so a stale 'openid' value doesn't affect logout + res.clearCookie('auth_method') + // Check if mobile app wants refresh token in response const returnTokens = req.headers['x-return-tokens'] === 'true' @@ -358,18 +282,26 @@ class Auth { // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', this.authRateLimiter, (req, res) => { - const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req) + // Validate callback URL for web flow + const callback = req.query.redirect_uri || req.query.callback + const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge + + if (!isMobileFlow) { + if (!callback) { + return res.status(400).send({ message: 'No callback parameter' }) + } + if (!this.oidcAuthStrategy.isValidWebCallbackUrl(callback, req)) { + Logger.warn(`[Auth] Rejected invalid callback URL: ${callback}`) + return res.status(400).send({ message: 'Invalid callback URL - must be same-origin' }) + } + } + + const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req, isMobileFlow, callback) if (authorizationUrlResponse.error) { return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error) } - // Check if paramsToCookies sent a response (e.g., due to invalid callback URL) - const cookieResult = this.paramsToCookies(req, res, authorizationUrlResponse.isMobileFlow ? 'openid-mobile' : 'openid') - if (cookieResult && cookieResult.error) { - return // Response already sent by paramsToCookies - } - res.redirect(authorizationUrlResponse.authorizationUrl) }) @@ -377,77 +309,66 @@ class Auth { // It will redirect to an app-link like audiobookshelf://oauth router.get('/auth/openid/mobile-redirect', this.authRateLimiter, (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) - // openid strategy callback route (this receives the token from the configured openid login provider) - router.get( - '/auth/openid/callback', - this.authRateLimiter, - (req, res, next) => { - const sessionKey = this.oidcAuthStrategy.getStrategy()._key + // openid strategy callback route - now uses direct token exchange (no passport) + router.get('/auth/openid/callback', this.authRateLimiter, async (req, res) => { + const isMobile = !!req.session.oidc?.isMobile + // Extract session data before cleanup (needed for redirect on success) + const callbackUrl = req.session.oidc?.callbackUrl - if (!req.session[sessionKey]) { - return res.status(400).send('No session') + try { + const user = await this.oidcAuthStrategy.handleCallback(req) + + // req.login still works (passport initialized for JWT/local) + await new Promise((resolve, reject) => { + req.login(user, (err) => (err ? reject(err) : resolve())) + }) + + // Create tokens and session, storing oidcIdToken in DB + const returnTokens = isMobile + const { accessToken, refreshToken } = await this.tokenManager.createTokensAndSession(user, req, user.openid_id_token) + + const userResponse = await this.getUserLoginResponsePayload(user) + userResponse.user.accessToken = accessToken + userResponse.user.refreshToken = returnTokens ? refreshToken : null + + // Set auth_method cookie + const authMethod = isMobile ? 'openid-mobile' : 'openid' + res.cookie('auth_method', authMethod, { + maxAge: 1000 * 60 * 60 * 24 * 365 * 10, + httpOnly: true, + secure: req.secure || req.get('x-forwarded-proto') === 'https', + sameSite: 'lax' + }) + + if (!returnTokens) { + this.tokenManager.setRefreshTokenCookie(req, res, refreshToken) } - // If the client sends us a code_verifier, we will tell passport to use this to send this in the token request - // The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request - // Crucial for API/Mobile clients - if (req.query.code_verifier) { - req.session[sessionKey].code_verifier = req.query.code_verifier - } - - function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) { - Logger.error(JSON.stringify(logMessage, null, 2)) - if (response) { - // Depending on the error, it can also have a body - // We also log the request header the passport plugin sents for the URL - const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED') - Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2)) - } - - if (isMobile) { - return res.status(errorCode).send(errorMessage) + if (isMobile) { + res.json(userResponse) + } else { + if (callbackUrl) { + // TODO: Temporarily continue sending the old token as setToken + res.redirect(302, `${callbackUrl}?setToken=${userResponse.user.token}&accessToken=${accessToken}`) } else { - return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`) + res.status(400).send('No callback URL') } } - - function passportCallback(req, res, next) { - return (err, user, info) => { - const isMobile = req.session[sessionKey]?.mobile === true - if (err) { - return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response) - } - - if (!user) { - // Info usually contains the error message from the SSO provider - return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response) - } - - req.logIn(user, (loginError) => { - if (loginError) { - return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`) - } - - // The id_token does not provide access to the user, but is used to identify the user to the SSO provider - // instead it containts a JWT with userinfo like user email, username, etc. - // the client will get to know it anyway in the logout url according to the oauth2 spec - // so it is safe to send it to the client, but we use strict settings - res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' }) - next() - }) - } + } catch (error) { + Logger.error(`[Auth] OIDC callback error: ${error.message}\n${error.stack}`) + if (isMobile) { + res.status(error.statusCode || 500).json({ error: error.message }) + } else { + res.redirect(`${global.RouterBasePath}/login?error=${encodeURIComponent(error.message)}&autoLaunch=0`) } - - // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request - // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided - // We set it here again because the passport param can change between requests - return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next) - }, - // on a successfull login: read the cookies and react like the client requested (callback or json) - this.handleLoginSuccessBasedOnCookie.bind(this) - ) + } finally { + // Clean up OIDC session data to prevent replay (on both success and error paths) + delete req.session.oidc + } + }) /** + * @deprecated Use POST /api/auth-settings/openid/discover instead. This route will be removed in a future version. * Helper route used to auto-populate the openid URLs in config/authentication * Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration" * @@ -473,7 +394,7 @@ class Auth { // Logout route router.post('/logout', async (req, res) => { - // Refresh token be alternatively be sent in the header + // Refresh token can alternatively be sent in the header const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token'] // Clear refresh token cookie @@ -481,8 +402,13 @@ class Auth { path: '/' }) - // Invalidate the session in database using refresh token + // Get oidcIdToken from DB session before invalidating (for OIDC logout) + let oidcIdToken = null if (refreshToken) { + const session = await this.tokenManager.getSessionByRefreshToken(refreshToken) + if (session) { + oidcIdToken = session.oidcIdToken + } await this.tokenManager.invalidateRefreshToken(refreshToken) } else { Logger.info(`[Auth] logout: No refresh token on request`) @@ -499,8 +425,7 @@ class Auth { let logoutUrl = null if (authMethod === 'openid' || authMethod === 'openid-mobile') { - logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod) - res.clearCookie('openid_id_token') + logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, oidcIdToken, authMethod) } // Tell the user agent (browser) to redirect to the authentification provider's logout URL diff --git a/server/auth/AuthError.js b/server/auth/AuthError.js new file mode 100644 index 000000000..7bb09ca47 --- /dev/null +++ b/server/auth/AuthError.js @@ -0,0 +1,9 @@ +class AuthError extends Error { + constructor(message, statusCode = 500) { + super(message) + this.statusCode = statusCode + this.name = 'AuthError' + } +} + +module.exports = AuthError diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js index 64ab82448..1dda12eaf 100644 --- a/server/auth/OidcAuthStrategy.js +++ b/server/auth/OidcAuthStrategy.js @@ -1,42 +1,20 @@ const { Request, Response } = require('express') -const passport = require('passport') const OpenIDClient = require('openid-client') const axios = require('axios') const Database = require('../Database') const Logger = require('../Logger') +const AuthError = require('./AuthError') /** - * OpenID Connect authentication strategy + * OpenID Connect authentication strategy (no Passport wrapper) */ class OidcAuthStrategy { constructor() { - this.name = 'openid-client' - this.strategy = null this.client = null // Map of openId sessions indexed by oauth2 state-variable this.openIdAuthSession = new Map() } - /** - * Get the passport strategy instance - * @returns {OpenIDClient.Strategy} - */ - getStrategy() { - if (!this.strategy) { - this.strategy = new OpenIDClient.Strategy( - { - client: this.getClient(), - params: { - redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, - scope: this.getScope() - } - }, - this.verifyCallback.bind(this) - ) - } - return this.strategy - } - /** * Get the OpenID Connect client * @returns {OpenIDClient.Client} @@ -44,7 +22,7 @@ class OidcAuthStrategy { getClient() { if (!this.client) { if (!Database.serverSettings.isOpenIDAuthSettingsValid) { - throw new Error('OpenID Connect settings are not valid') + throw new AuthError('OpenID Connect settings are not valid', 500) } // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing @@ -73,60 +51,99 @@ class OidcAuthStrategy { * @returns {string} */ getScope() { - let scope = 'openid profile email' - if (global.ServerSettings.authOpenIDGroupClaim) { - scope += ' ' + global.ServerSettings.authOpenIDGroupClaim - } - if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { - scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim - } - return scope + return global.ServerSettings.authOpenIDScopes || 'openid profile email' } /** - * Initialize the strategy with passport + * Reload the OIDC strategy after settings change (replaces init/unuse) */ - init() { - if (!Database.serverSettings.isOpenIDAuthSettingsValid) { - Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`) - return - } - passport.use(this.name, this.getStrategy()) - } - - /** - * Remove the strategy from passport - */ - unuse() { - passport.unuse(this.name) - this.strategy = null + reload() { this.client = null + this.openIdAuthSession.clear() + Logger.info('[OidcAuth] Settings reloaded') } /** - * Verify callback for OpenID Connect authentication + * Clean up stale mobile auth sessions older than 10 minutes + */ + cleanupStaleAuthSessions() { + const maxAge = 10 * 60 * 1000 // 10 minutes + const now = Date.now() + for (const [state, session] of this.openIdAuthSession) { + if (now - (session.created_at || 0) > maxAge) { + this.openIdAuthSession.delete(state) + } + } + } + + /** + * Handle the OIDC callback - exchange auth code for tokens and verify user. + * Replaces the passport authenticate + verifyCallback flow. + * + * @param {Request} req + * @returns {Promise} authenticated user + * @throws {AuthError} + */ + async handleCallback(req) { + const sessionData = req.session.oidc + if (!sessionData) { + throw new AuthError('No OIDC session found', 400) + } + + const client = this.getClient() + + // If the client sends a code_verifier in query, use it (mobile flow) + const codeVerifier = req.query.code_verifier || sessionData.code_verifier + + // Exchange auth code for tokens + const params = client.callbackParams(req) + const tokenset = await client.callback(sessionData.sso_redirect_uri, params, { + state: sessionData.state, + code_verifier: codeVerifier, + response_type: 'code' + }) + + // Fetch userinfo + const userinfo = await client.userinfo(tokenset.access_token) + + // Verify and find/create user + const user = await this.verifyUser(tokenset, userinfo) + + return user + } + + /** + * Verify user from OIDC token set and userinfo. + * Returns user directly or throws AuthError. + * * @param {Object} tokenset * @param {Object} userinfo - * @param {Function} done - Passport callback + * @returns {Promise} + * @throws {AuthError} */ - async verifyCallback(tokenset, userinfo, done) { + async verifyUser(tokenset, userinfo) { let isNewUser = false let user = null try { Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) if (!userinfo.sub) { - throw new Error('Invalid userinfo, no sub') + throw new AuthError('Invalid userinfo, no sub', 401) } if (!this.validateGroupClaim(userinfo)) { - throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) + throw new AuthError(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`, 401) + } + + // Enforce email_verified check on every login if configured + if (global.ServerSettings.authOpenIDRequireVerifiedEmail && userinfo.email_verified === false) { + throw new AuthError('Email is not verified', 401) } user = await Database.userModel.findUserFromOpenIdUserInfo(userinfo) if (user?.error) { - throw new Error('Invalid userinfo or already linked') + throw new AuthError('Invalid userinfo or already linked', 401) } if (!user) { @@ -137,27 +154,31 @@ class OidcAuthStrategy { isNewUser = true } else { Logger.warn(`[User] openid: User not found and auto-register is disabled`) + throw new AuthError('User not found and auto-register is disabled', 401) } } if (!user.isActive) { - throw new Error('User not active or not found') + throw new AuthError('User not active or not found', 401) } await this.setUserGroup(user, userinfo) await this.updateUserPermissions(user, userinfo) - // We also have to save the id_token for later (used for logout) because we cannot set cookies here + // Save the id_token for later (used for logout via DB session) user.openid_id_token = tokenset.id_token - return done(null, user) + return user } catch (error) { Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`) // Remove new user if an error occurs if (isNewUser && user) { await user.destroy() } - return done(null, null, 'Unauthorized') + if (error instanceof AuthError) { + throw error + } + throw new AuthError(error.message || 'Unauthorized', 401) } } @@ -181,6 +202,8 @@ class OidcAuthStrategy { /** * Sets the user group based on group claim in userinfo. + * Supports explicit group mapping via authOpenIDGroupMap or legacy direct name match. + * * @param {import('../models/User')} user * @param {Object} userinfo */ @@ -190,17 +213,35 @@ class OidcAuthStrategy { // No group claim configured, don't set anything return - if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`) + if (!userinfo[groupClaimName]) throw new AuthError(`Group claim ${groupClaimName} not found in userinfo`, 401) const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) const rolesInOrderOfPriority = ['admin', 'user', 'guest'] + const groupMap = global.ServerSettings.authOpenIDGroupMap || {} + + let userType = null + + if (Object.keys(groupMap).length > 0) { + // Explicit group mapping: iterate roles in priority order, check if any mapped group names match + for (const role of rolesInOrderOfPriority) { + const mappedGroups = Object.entries(groupMap) + .filter(([, v]) => v === role) + .map(([k]) => k.toLowerCase()) + if (mappedGroups.some((g) => groupsList.includes(g))) { + userType = role + break + } + } + } else { + // Legacy direct name match + userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) + } - let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) if (userType) { if (user.type === 'root') { // Check OpenID Group if (userType !== 'admin') { - throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`) + throw new AuthError(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`, 403) } else { // If root user is logging in via OpenID, we will not change the type return @@ -213,7 +254,7 @@ class OidcAuthStrategy { await user.save() } } else { - throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) + throw new AuthError(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`, 401) } } @@ -231,7 +272,7 @@ class OidcAuthStrategy { if (user.type === 'admin' || user.type === 'root') return const absPermissions = userinfo[absPermissionsClaim] - if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) + if (!absPermissions) throw new AuthError(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`, 401) if (await user.updatePermissionsFromExternalJSON(absPermissions)) { Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) @@ -274,24 +315,23 @@ class OidcAuthStrategy { */ isValidRedirectUri(uri) { // Check if the redirect_uri is in the whitelist - return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*') + return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) } /** * Get the authorization URL for OpenID Connect * Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow * @param {Request} req - * @returns {{ authorizationUrl: string }|{status: number, error: string}} + * @param {boolean} isMobileFlow - whether this is a mobile client flow (determined by caller) + * @param {string|undefined} validatedCallback - pre-validated callback URL for web flow + * @returns {{ authorizationUrl: string, isMobileFlow: boolean }|{status: number, error: string}} */ - getAuthorizationUrl(req) { + getAuthorizationUrl(req, isMobileFlow, validatedCallback) { const client = this.getClient() - const strategy = this.getStrategy() - const sessionKey = strategy._key try { const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' const hostUrl = new URL(`${protocol}://${req.get('host')}`) - const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge // Only allow code flow (for mobile clients) if (req.query.response_type && req.query.response_type !== 'code') { @@ -309,8 +349,6 @@ class OidcAuthStrategy { let redirectUri if (isMobileFlow) { // Mobile required redirect uri - // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect - // where we will handle the redirect to it if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) { Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`) return { @@ -318,9 +356,10 @@ class OidcAuthStrategy { error: 'Invalid redirect_uri' } } - // We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API + // We cannot save the supplied redirect_uri in the session, because the mobile client uses browser instead of the API // for the request to mobile-redirect and as such the session is not shared - this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) + this.cleanupStaleAuthSessions() + this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri, created_at: Date.now() }) redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() } else { @@ -335,8 +374,6 @@ class OidcAuthStrategy { } } - // Update the strategy's redirect_uri for this request - strategy._params.redirect_uri = redirectUri Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`) const pkceData = this.generatePkce(req, isMobileFlow) @@ -347,18 +384,17 @@ class OidcAuthStrategy { } } - req.session[sessionKey] = { - ...req.session[sessionKey], + // Store OIDC session data using fixed key 'oidc' + req.session.oidc = { state: state, - max_age: strategy._params.max_age, response_type: 'code', code_verifier: pkceData.code_verifier, // not null if web flow - mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out - sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback + isMobile: !!isMobileFlow, + sso_redirect_uri: redirectUri, // Save the redirect_uri (for the SSO Provider) for the callback + callbackUrl: !isMobileFlow ? validatedCallback : undefined // web: pre-validated callback URL } const authorizationUrl = client.authorizationUrl({ - ...strategy._params, redirect_uri: redirectUri, state: state, response_type: 'code', @@ -396,18 +432,11 @@ class OidcAuthStrategy { if (authMethod === 'openid') { const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' const host = req.get('host') - // TODO: ABS does currently not support subfolders for installation - // If we want to support it we need to include a config for the serverurl postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login` } // else for openid-mobile we keep postLogoutRedirectUri on null - // nice would be to redirect to the app here, but for example Authentik does not implement - // the post_logout_redirect_uri parameter at all and for other providers - // we would also need again to implement (and even before get to know somehow for 3rd party apps) - // the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect). - // Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like - // &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution - // (The URL needs to be whitelisted in the config of the SSO/ID provider) + // The client/app can simply append something like + // &post_logout_redirect_uri=audiobookshelf://login to the received logout url return client.endSessionUrl({ id_token_hint: idToken, diff --git a/server/auth/OidcSettingsSchema.js b/server/auth/OidcSettingsSchema.js new file mode 100644 index 000000000..09b9cf801 --- /dev/null +++ b/server/auth/OidcSettingsSchema.js @@ -0,0 +1,340 @@ +const groups = [ + { id: 'endpoints', label: 'Provider Endpoints', order: 1 }, + { id: 'credentials', label: 'Client Credentials', order: 2 }, + { id: 'behavior', label: 'Login Behavior', order: 3 }, + { id: 'claims', label: 'Claims & Group Mapping', order: 4 }, + { id: 'advanced', label: 'Advanced', order: 5 } +] + +const schema = [ + // Endpoints group + { + key: 'authOpenIDIssuerURL', + type: 'text', + label: 'Issuer URL', + group: 'endpoints', + order: 1, + required: true, + validate: 'url' + }, + { + key: 'discover', + type: 'action', + label: 'Auto-populate', + group: 'endpoints', + order: 2, + description: 'Fetch endpoints from issuer discovery document', + dependsOn: 'authOpenIDIssuerURL' + }, + { + key: 'authOpenIDAuthorizationURL', + type: 'text', + label: 'Authorize URL', + group: 'endpoints', + order: 3, + required: true, + validate: 'url' + }, + { + key: 'authOpenIDTokenURL', + type: 'text', + label: 'Token URL', + group: 'endpoints', + order: 4, + required: true, + validate: 'url' + }, + { + key: 'authOpenIDUserInfoURL', + type: 'text', + label: 'Userinfo URL', + group: 'endpoints', + order: 5, + required: true, + validate: 'url' + }, + { + key: 'authOpenIDJwksURL', + type: 'text', + label: 'JWKS URL', + group: 'endpoints', + order: 6, + required: true, + validate: 'url' + }, + { + key: 'authOpenIDLogoutURL', + type: 'text', + label: 'Logout URL', + group: 'endpoints', + order: 7, + validate: 'url' + }, + + // Credentials group + { + key: 'authOpenIDClientID', + type: 'text', + label: 'Client ID', + group: 'credentials', + order: 1, + required: true + }, + { + key: 'authOpenIDClientSecret', + type: 'password', + label: 'Client Secret', + group: 'credentials', + order: 2, + required: true + }, + { + key: 'authOpenIDTokenSigningAlgorithm', + type: 'select', + label: 'Signing Algorithm', + group: 'credentials', + order: 3, + required: true, + default: 'RS256', + options: [ + { value: 'RS256', label: 'RS256' }, + { value: 'RS384', label: 'RS384' }, + { value: 'RS512', label: 'RS512' }, + { value: 'ES256', label: 'ES256' }, + { value: 'ES384', label: 'ES384' }, + { value: 'ES512', label: 'ES512' }, + { value: 'PS256', label: 'PS256' }, + { value: 'PS384', label: 'PS384' }, + { value: 'PS512', label: 'PS512' }, + { value: 'EdDSA', label: 'EdDSA' } + ] + }, + + // Behavior group + { + key: 'authOpenIDButtonText', + type: 'text', + label: 'Button Text', + group: 'behavior', + order: 1, + default: 'Login with OpenId' + }, + { + key: 'authOpenIDMatchExistingBy', + type: 'select', + label: 'Match Existing Users By', + group: 'behavior', + order: 2, + options: [ + { value: null, label: 'Do not match' }, + { value: 'email', label: 'Match by email' }, + { value: 'username', label: 'Match by username' } + ] + }, + { + key: 'authOpenIDAutoLaunch', + type: 'boolean', + label: 'Auto Launch', + group: 'behavior', + order: 3, + description: 'Automatically redirect to the OpenID provider on login page' + }, + { + key: 'authOpenIDAutoRegister', + type: 'boolean', + label: 'Auto Register', + group: 'behavior', + order: 4, + description: 'Automatically register new users from the OpenID provider' + }, + { + key: 'authOpenIDRequireVerifiedEmail', + type: 'boolean', + label: 'Require Verified Email', + group: 'behavior', + order: 5, + description: 'Reject login if email_verified is false in the OIDC userinfo, even for existing users' + }, + + // Claims group + { + key: 'authOpenIDScopes', + type: 'text', + label: 'Scopes', + group: 'claims', + order: 1, + default: 'openid profile email', + description: 'Space-separated list of OIDC scopes to request' + }, + { + key: 'authOpenIDGroupClaim', + type: 'text', + label: 'Group Claim', + group: 'claims', + order: 2, + validate: 'claimName', + description: 'Name of the claim containing group membership' + }, + { + key: 'authOpenIDGroupMap', + type: 'keyvalue', + label: 'Group Mapping', + group: 'claims', + order: 3, + valueOptions: ['admin', 'user', 'guest'], + description: 'Map OIDC group names to Audiobookshelf roles. If empty, groups are matched by name (admin/user/guest).', + dependsOn: 'authOpenIDGroupClaim' + }, + { + key: 'authOpenIDAdvancedPermsClaim', + type: 'text', + label: 'Advanced Permission Claim', + group: 'claims', + order: 4, + validate: 'claimName', + description: 'Claim containing per-user permissions JSON' + }, + + // Advanced group + { + key: 'authOpenIDMobileRedirectURIs', + type: 'array', + label: 'Mobile Redirect URIs', + group: 'advanced', + order: 1, + default: ['audiobookshelf://oauth'], + validate: 'uri', + description: 'Allowed redirect URIs for mobile clients.' + }, + { + key: 'authOpenIDSubfolderForRedirectURLs', + type: 'select', + label: 'Web Redirect URLs Subfolder', + group: 'advanced', + order: 2, + options: [ + { value: '', label: 'None' } + ], + description: 'Subfolder prefix for redirect URLs (e.g. /audiobookshelf)' + } +] + +/** + * Get the OIDC settings schema + * @returns {Array} schema field descriptors + */ +function getSchema() { + // Lazily resolve sample permissions to avoid circular dependency at require time + const User = require('../models/User') + return schema.map((field) => { + if (field.key === 'authOpenIDAdvancedPermsClaim') { + return { + ...field, + description: `Claim containing per-user permissions JSON. Sample: ${User.getSampleAbsPermissions()}` + } + } + return field + }) +} + +/** + * Get the OIDC settings groups + * @returns {Array} group descriptors + */ +function getGroups() { + return groups +} + +/** + * Validate OIDC settings values against the schema + * @param {Object} values - key-value pairs of settings + * @returns {{ valid: boolean, errors?: string[] }} + */ +function validateSettings(values) { + const errors = [] + + // Reject unknown keys + const knownKeys = new Set(schema.filter((f) => f.type !== 'action').map((f) => f.key)) + for (const key of Object.keys(values)) { + if (!knownKeys.has(key)) { + errors.push(`Unknown setting: "${key}"`) + } + } + + for (const field of schema) { + if (field.type === 'action') continue + + const value = values[field.key] + + // Check required fields + if (field.required) { + if (value === undefined || value === null || value === '') { + errors.push(`${field.label} is required`) + continue + } + } + + // Skip validation for empty optional fields + if (value === undefined || value === null || value === '') continue + + // Type-specific validation + if (field.validate === 'url') { + try { + new URL(value) + } catch { + errors.push(`${field.label}: Invalid URL`) + } + } + + if (field.validate === 'uri') { + if (Array.isArray(value)) { + const uriPattern = /^\w+:\/\/[\w.-]+(\/[\w./-]*)*$/i + for (const uri of value) { + if (!uriPattern.test(uri)) { + errors.push(`${field.label}: Invalid URI "${uri}"`) + } + } + } + } + + if (field.validate === 'claimName') { + if (typeof value === 'string' && value !== '') { + const claimPattern = /^[a-zA-Z][a-zA-Z0-9_:./-]*$/ + if (!claimPattern.test(value)) { + errors.push(`${field.label}: Invalid claim name`) + } + } + } + + if (field.type === 'boolean') { + if (typeof value !== 'boolean') { + errors.push(`${field.label}: Expected boolean`) + } + } + + if (field.type === 'array') { + if (!Array.isArray(value)) { + errors.push(`${field.label}: Expected array`) + } + } + + if (field.type === 'keyvalue') { + if (typeof value !== 'object' || Array.isArray(value) || value === null) { + errors.push(`${field.label}: Expected object`) + } else if (field.valueOptions) { + for (const [k, v] of Object.entries(value)) { + if (!field.valueOptions.includes(v)) { + errors.push(`${field.label}: Invalid value "${v}" for key "${k}". Must be one of: ${field.valueOptions.join(', ')}`) + } + } + } + } + } + + if (errors.length > 0) { + return { valid: false, errors } + } + return { valid: true } +} + +module.exports = { getSchema, getGroups, validateSettings } diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index faa6774a3..465e470ee 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -156,9 +156,10 @@ class TokenManager { * * @param {{ id:string, username:string }} user * @param {import('express').Request} req + * @param {string|null} [oidcIdToken=null] - OIDC id_token to store in session for logout * @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>} */ - async createTokensAndSession(user, req) { + async createTokensAndSession(user, req, oidcIdToken = null) { const ipAddress = requestIp.getClientIp(req) const userAgent = req.headers['user-agent'] const accessToken = this.generateTempAccessToken(user) @@ -167,7 +168,7 @@ class TokenManager { // Calculate expiration time for the refresh token const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000) - const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt) + const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken) return { accessToken, @@ -392,6 +393,17 @@ class TokenManager { return null } + /** + * Get a session by its refresh token + * + * @param {string} refreshToken + * @returns {Promise} + */ + async getSessionByRefreshToken(refreshToken) { + if (!refreshToken) return null + return await Database.sessionModel.findOne({ where: { refreshToken } }) + } + /** * Invalidate a refresh token - used for logout * diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 490cb27d2..4bd6ba4e8 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -14,6 +14,7 @@ const { sanitizeFilename } = require('../utils/fileUtils') const TaskManager = require('../managers/TaskManager') const adminStats = require('../utils/queries/adminStats') +const OidcSettingsSchema = require('../auth/OidcSettingsSchema') /** * @typedef RequestUserObject @@ -625,7 +626,16 @@ class MiscController { Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`) return res.sendStatus(403) } - return res.json(Database.serverSettings.authenticationSettings) + + const schema = OidcSettingsSchema.getSchema() + const groups = OidcSettingsSchema.getGroups() + const values = Database.serverSettings.openIDSettingsValues + + return res.json({ + authLoginCustomMessage: Database.serverSettings.authLoginCustomMessage, + authActiveAuthMethods: Database.serverSettings.authActiveAuthMethods, + openIDSettings: { schema, groups, values } + }) } /** @@ -648,73 +658,83 @@ class MiscController { let hasUpdates = false - const currentAuthenticationSettings = Database.serverSettings.authenticationSettings - const originalAuthMethods = [...currentAuthenticationSettings.authActiveAuthMethods] + const originalAuthMethods = [...Database.serverSettings.authActiveAuthMethods] + const originalLoginMessage = Database.serverSettings.authLoginCustomMessage - // TODO: Better validation of auth settings once auth settings are separated from server settings - for (const key in currentAuthenticationSettings) { - if (settingsUpdate[key] === undefined) continue + // 1. Update static settings (authLoginCustomMessage, authActiveAuthMethods) + if (settingsUpdate.authLoginCustomMessage !== undefined) { + const newValue = settingsUpdate.authLoginCustomMessage || null + if (newValue !== Database.serverSettings.authLoginCustomMessage) { + Database.serverSettings.authLoginCustomMessage = newValue + hasUpdates = true + } + } - if (key === 'authActiveAuthMethods') { - let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth)) - if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) { - updatedAuthMethods.sort() - currentAuthenticationSettings[key].sort() - if (updatedAuthMethods.join() !== currentAuthenticationSettings[key].join()) { - Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentAuthenticationSettings[key].join()}" to "${updatedAuthMethods.join()}"`) - Database.serverSettings[key] = updatedAuthMethods - hasUpdates = true - } - } else { - Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`) - } - } else if (key === 'authOpenIDMobileRedirectURIs') { - function isValidRedirectURI(uri) { - if (typeof uri !== 'string') return false - const pattern = new RegExp('^\\w+://[\\w\\.-]+(/[\\w\\./-]*)*$', 'i') - return pattern.test(uri) - } - - const uris = settingsUpdate[key] - if (!Array.isArray(uris) || (uris.includes('*') && uris.length > 1) || uris.some((uri) => uri !== '*' && !isValidRedirectURI(uri))) { - Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`) - continue - } - - // Update the URIs - if (Database.serverSettings[key].some((uri) => !uris.includes(uri)) || uris.some((uri) => !Database.serverSettings[key].includes(uri))) { - Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`) - Database.serverSettings[key] = uris + if (settingsUpdate.authActiveAuthMethods !== undefined) { + let updatedAuthMethods = settingsUpdate.authActiveAuthMethods?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth)) + if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) { + updatedAuthMethods.sort() + const currentSorted = [...Database.serverSettings.authActiveAuthMethods].sort() + if (updatedAuthMethods.join() !== currentSorted.join()) { + Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentSorted.join()}" to "${updatedAuthMethods.join()}"`) + Database.serverSettings.authActiveAuthMethods = updatedAuthMethods hasUpdates = true } } else { - const updatedValueType = typeof settingsUpdate[key] - if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) { - if (updatedValueType !== 'boolean') { - Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`) - continue - } - } else if (settingsUpdate[key] !== null && updatedValueType !== 'string') { - Logger.warn(`[MiscController] Invalid value for ${key}. Expected string or null`) - continue - } - let updatedValue = settingsUpdate[key] - if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null - let currentValue = currentAuthenticationSettings[key] - if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null + Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`) + } + } - if (updatedValue !== currentValue) { - Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`) - Database.serverSettings[key] = updatedValue + // Reject enabling openid without valid OIDC configuration + if (Database.serverSettings.authActiveAuthMethods.includes('openid') && !originalAuthMethods.includes('openid')) { + if (!Database.serverSettings.isOpenIDAuthSettingsValid && !settingsUpdate.openIDSettings) { + Logger.warn(`[MiscController] Cannot enable openid auth without valid OIDC configuration`) + Database.serverSettings.authActiveAuthMethods = originalAuthMethods + return res.status(400).json({ error: 'Cannot enable OpenID auth without valid OIDC configuration. Configure OIDC settings first.' }) + } + } + + // 2. Update OIDC settings via schema validation + if (settingsUpdate.openIDSettings && isObject(settingsUpdate.openIDSettings)) { + const oidcValues = settingsUpdate.openIDSettings + const validation = OidcSettingsSchema.validateSettings(oidcValues) + if (!validation.valid) { + // Rollback any in-memory changes made before validation + Database.serverSettings.authActiveAuthMethods = originalAuthMethods + Database.serverSettings.authLoginCustomMessage = originalLoginMessage + return res.status(400).json({ error: 'Invalid OIDC settings', details: validation.errors }) + } + + // Apply validated OIDC settings + const currentValues = Database.serverSettings.openIDSettingsValues + for (const key of Object.keys(currentValues)) { + if (oidcValues[key] === undefined) continue + + const newValue = oidcValues[key] + const currentValue = currentValues[key] + + // Deep comparison for objects/arrays + const newStr = JSON.stringify(newValue) + const curStr = JSON.stringify(currentValue) + + if (newStr !== curStr) { + Logger.debug(`[MiscController] Updating OIDC setting "${key}"`) + Database.serverSettings[key] = newValue hasUpdates = true } } + + // Live reload OIDC strategy if settings changed (only when openid was already active, + // since the use/unuse block below handles the case where openid is being newly enabled) + if (hasUpdates && Database.serverSettings.authActiveAuthMethods.includes('openid') && originalAuthMethods.includes('openid')) { + this.auth.oidcAuthStrategy.reload() + } } if (hasUpdates) { await Database.updateServerSettings() - // Use/unuse auth methods + // Use/unuse auth methods (this calls reload() for newly enabled/disabled openid) Database.serverSettings.supportedAuthMethods.forEach((authMethod) => { if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) { // Auth method has been removed @@ -734,6 +754,56 @@ class MiscController { }) } + /** + * POST: api/auth-settings/openid/discover + * Discover OpenID Connect configuration from an issuer URL + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async discoverOpenIDConfig(req, res) { + if (!req.user.isAdminOrUp) { + Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to discover OIDC config`) + return res.sendStatus(403) + } + + const { issuerUrl } = req.body + if (!issuerUrl) { + return res.status(400).json({ error: 'issuerUrl required' }) + } + + try { + const config = await this.auth.oidcAuthStrategy.getIssuerConfig(issuerUrl) + if (config.error) { + return res.status(config.status).json({ error: config.error }) + } + + // Map discovery to setting values + const values = { + authOpenIDIssuerURL: config.issuer, + authOpenIDAuthorizationURL: config.authorization_endpoint, + authOpenIDTokenURL: config.token_endpoint, + authOpenIDUserInfoURL: config.userinfo_endpoint, + authOpenIDJwksURL: config.jwks_uri, + authOpenIDLogoutURL: config.end_session_endpoint || null, + authOpenIDTokenSigningAlgorithm: config.id_token_signing_alg_values_supported?.[0] || 'RS256' + } + + const schemaOverrides = {} + if (config.id_token_signing_alg_values_supported?.length) { + schemaOverrides.authOpenIDTokenSigningAlgorithm = { + type: 'select', + options: config.id_token_signing_alg_values_supported.map((alg) => ({ value: alg, label: alg })) + } + } + + res.json({ values, schemaOverrides }) + } catch (error) { + Logger.error(`[MiscController] Error discovering OIDC config: ${error.message}`) + return res.status(500).json({ error: 'Failed to discover OIDC configuration' }) + } + } + /** * GET: /api/stats/year/:year * diff --git a/server/migrations/v2.33.0-oidc-scopes-and-group-map.js b/server/migrations/v2.33.0-oidc-scopes-and-group-map.js new file mode 100644 index 000000000..feb7fb95b --- /dev/null +++ b/server/migrations/v2.33.0-oidc-scopes-and-group-map.js @@ -0,0 +1,143 @@ +/** + * @typedef MigrationContext + * @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object. + * @property {import('../Logger')} logger - a Logger object. + * + * @typedef MigrationOptions + * @property {MigrationContext} context - an object containing the migration context. + */ + +const migrationVersion = '2.33.0' +const migrationName = `${migrationVersion}-oidc-scopes-and-group-map` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This migration adds oidcIdToken column to sessions table and computes + * authOpenIDScopes / authOpenIDGroupMap from existing OIDC config. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function up({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`) + + // 2a: Add oidcIdToken column to sessions table + if (await queryInterface.tableExists('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + if (!tableDescription.oidcIdToken) { + logger.info(`${loggerPrefix} Adding oidcIdToken column to sessions table`) + await queryInterface.addColumn('sessions', 'oidcIdToken', { + type: queryInterface.sequelize.Sequelize.DataTypes.TEXT, + allowNull: true + }) + logger.info(`${loggerPrefix} Added oidcIdToken column to sessions table`) + } else { + logger.info(`${loggerPrefix} oidcIdToken column already exists in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + // 2b: Compute authOpenIDScopes from existing config + // NOTE: This preserves backward compatibility by appending claim names as scopes. + // In OIDC, claim names and scope names are not always the same (e.g., a "groups" claim + // might be included via the "openid" scope). Users may need to adjust scopes after upgrade. + const serverSettings = await getServerSettings(queryInterface, logger) + + if (serverSettings.authOpenIDScopes === undefined) { + let scope = 'openid profile email' + if (serverSettings.authOpenIDGroupClaim) { + scope += ' ' + serverSettings.authOpenIDGroupClaim + } + if (serverSettings.authOpenIDAdvancedPermsClaim) { + scope += ' ' + serverSettings.authOpenIDAdvancedPermsClaim + } + serverSettings.authOpenIDScopes = scope.trim() + logger.info(`${loggerPrefix} Computed authOpenIDScopes: "${serverSettings.authOpenIDScopes}"`) + } else { + logger.info(`${loggerPrefix} authOpenIDScopes already exists in server settings`) + } + + if (serverSettings.authOpenIDGroupMap === undefined) { + serverSettings.authOpenIDGroupMap = {} + logger.info(`${loggerPrefix} Initialized authOpenIDGroupMap`) + } else { + logger.info(`${loggerPrefix} authOpenIDGroupMap already exists in server settings`) + } + + await updateServerSettings(queryInterface, logger, serverSettings) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This migration removes oidcIdToken column from sessions table and + * removes authOpenIDScopes / authOpenIDGroupMap from server settings. + * + * @param {MigrationOptions} options - an object containing the migration context. + * @returns {Promise} - A promise that resolves when the migration is complete. + */ +async function down({ context: { queryInterface, logger } }) { + logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`) + + // Remove oidcIdToken column from sessions table + if (await queryInterface.tableExists('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + if (tableDescription.oidcIdToken) { + logger.info(`${loggerPrefix} Removing oidcIdToken column from sessions table`) + await queryInterface.removeColumn('sessions', 'oidcIdToken') + logger.info(`${loggerPrefix} Removed oidcIdToken column from sessions table`) + } else { + logger.info(`${loggerPrefix} oidcIdToken column does not exist in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + // Remove authOpenIDScopes and authOpenIDGroupMap from server settings + const serverSettings = await getServerSettings(queryInterface, logger) + let changed = false + if (serverSettings.authOpenIDScopes !== undefined) { + delete serverSettings.authOpenIDScopes + changed = true + logger.info(`${loggerPrefix} Removed authOpenIDScopes from server settings`) + } + if (serverSettings.authOpenIDGroupMap !== undefined) { + delete serverSettings.authOpenIDGroupMap + changed = true + logger.info(`${loggerPrefix} Removed authOpenIDGroupMap from server settings`) + } + if (changed) { + await updateServerSettings(queryInterface, logger, serverSettings) + } + + logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`) +} + +async function getServerSettings(queryInterface, logger) { + const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";') + if (!result[0].length) { + logger.error(`${loggerPrefix} Server settings not found`) + throw new Error('Server settings not found') + } + + let serverSettings = null + try { + serverSettings = JSON.parse(result[0][0].value) + } catch (error) { + logger.error(`${loggerPrefix} Error parsing server settings:`, error) + throw error + } + + return serverSettings +} + +async function updateServerSettings(queryInterface, logger, serverSettings) { + await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', { + replacements: { + value: JSON.stringify(serverSettings) + } + }) +} + +module.exports = { up, down } diff --git a/server/models/Session.js b/server/models/Session.js index fe9dd5425..7316c88cd 100644 --- a/server/models/Session.js +++ b/server/models/Session.js @@ -18,6 +18,8 @@ class Session extends Model { this.userId /** @type {Date} */ this.expiresAt + /** @type {string} */ + this.oidcIdToken // Expanded properties @@ -25,8 +27,8 @@ class Session extends Model { this.user } - static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) { - const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt }) + static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken = null) { + const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken }) return session } @@ -66,7 +68,8 @@ class Session extends Model { expiresAt: { type: DataTypes.DATE, allowNull: false - } + }, + oidcIdToken: DataTypes.TEXT }, { sequelize, diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index a03e17c75..36139af37 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -82,6 +82,9 @@ class ServerSettings { this.authOpenIDGroupClaim = '' this.authOpenIDAdvancedPermsClaim = '' this.authOpenIDSubfolderForRedirectURLs = undefined + this.authOpenIDScopes = 'openid profile email' + this.authOpenIDGroupMap = {} + this.authOpenIDRequireVerifiedEmail = false if (settings) { this.construct(settings) @@ -146,6 +149,9 @@ class ServerSettings { this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || '' this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || '' this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs + this.authOpenIDScopes = settings.authOpenIDScopes || 'openid profile email' + this.authOpenIDGroupMap = settings.authOpenIDGroupMap || {} + this.authOpenIDRequireVerifiedEmail = !!settings.authOpenIDRequireVerifiedEmail if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -255,7 +261,10 @@ class ServerSettings { authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client - authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs + authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs, + authOpenIDScopes: this.authOpenIDScopes, + authOpenIDGroupMap: this.authOpenIDGroupMap, + authOpenIDRequireVerifiedEmail: this.authOpenIDRequireVerifiedEmail } } @@ -267,6 +276,9 @@ class ServerSettings { delete json.authOpenIDMobileRedirectURIs delete json.authOpenIDGroupClaim delete json.authOpenIDAdvancedPermsClaim + delete json.authOpenIDScopes + delete json.authOpenIDGroupMap + delete json.authOpenIDRequireVerifiedEmail return json } @@ -281,29 +293,41 @@ class ServerSettings { return this.authOpenIDIssuerURL && this.authOpenIDAuthorizationURL && this.authOpenIDTokenURL && this.authOpenIDUserInfoURL && this.authOpenIDJwksURL && this.authOpenIDClientID && this.authOpenIDClientSecret && this.authOpenIDTokenSigningAlgorithm } - get authenticationSettings() { + /** + * All OIDC-related setting keys (values only, for admin API) + */ + get openIDSettingsValues() { return { - authLoginCustomMessage: this.authLoginCustomMessage, - authActiveAuthMethods: this.authActiveAuthMethods, authOpenIDIssuerURL: this.authOpenIDIssuerURL, authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL, authOpenIDTokenURL: this.authOpenIDTokenURL, authOpenIDUserInfoURL: this.authOpenIDUserInfoURL, authOpenIDJwksURL: this.authOpenIDJwksURL, authOpenIDLogoutURL: this.authOpenIDLogoutURL, - authOpenIDClientID: this.authOpenIDClientID, // Do not return to client - authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client + authOpenIDClientID: this.authOpenIDClientID, + authOpenIDClientSecret: this.authOpenIDClientSecret, authOpenIDTokenSigningAlgorithm: this.authOpenIDTokenSigningAlgorithm, authOpenIDButtonText: this.authOpenIDButtonText, authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, - authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client - authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client - authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, + authOpenIDGroupClaim: this.authOpenIDGroupClaim, + authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs, + authOpenIDScopes: this.authOpenIDScopes, + authOpenIDGroupMap: this.authOpenIDGroupMap, + authOpenIDRequireVerifiedEmail: this.authOpenIDRequireVerifiedEmail + } + } - authOpenIDSamplePermissions: User.getSampleAbsPermissions() + get authenticationSettings() { + return { + authLoginCustomMessage: this.authLoginCustomMessage, + authActiveAuthMethods: this.authActiveAuthMethods, + openIDSettings: { + values: this.openIDSettingsValues + } } } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db04bf5ec..89d2d4196 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -352,6 +352,7 @@ class ApiRouter { this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this)) this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this)) this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this)) + this.router.post('/auth-settings/openid/discover', MiscController.discoverOpenIDConfig.bind(this)) this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this)) this.router.get('/logger-data', MiscController.getLoggerData.bind(this)) } diff --git a/test/server/auth/AuthError.test.js b/test/server/auth/AuthError.test.js new file mode 100644 index 000000000..a6efff218 --- /dev/null +++ b/test/server/auth/AuthError.test.js @@ -0,0 +1,24 @@ +const { expect } = require('chai') +const AuthError = require('../../../server/auth/AuthError') + +describe('AuthError', function () { + it('should create error with default statusCode 500', function () { + const error = new AuthError('Something went wrong') + expect(error.message).to.equal('Something went wrong') + expect(error.statusCode).to.equal(500) + expect(error.name).to.equal('AuthError') + expect(error).to.be.instanceOf(Error) + }) + + it('should create error with custom statusCode', function () { + const error = new AuthError('Unauthorized', 401) + expect(error.message).to.equal('Unauthorized') + expect(error.statusCode).to.equal(401) + }) + + it('should have a stack trace', function () { + const error = new AuthError('test') + expect(error.stack).to.be.a('string') + expect(error.stack).to.include('AuthError') + }) +}) diff --git a/test/server/auth/OidcAuthStrategy.test.js b/test/server/auth/OidcAuthStrategy.test.js new file mode 100644 index 000000000..baac85ad0 --- /dev/null +++ b/test/server/auth/OidcAuthStrategy.test.js @@ -0,0 +1,246 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const AuthError = require('../../../server/auth/AuthError') + +// We test setUserGroup in isolation by creating a minimal instance +// and stubbing the globals it depends on +describe('OidcAuthStrategy - setUserGroup', function () { + let OidcAuthStrategy, strategy + + before(function () { + // Stub global dependencies that OidcAuthStrategy requires at import time + global.ServerSettings = { + authOpenIDGroupClaim: '', + authOpenIDGroupMap: {}, + authOpenIDScopes: 'openid profile email', + isOpenIDAuthSettingsValid: false, + authOpenIDMobileRedirectURIs: [] + } + // Stub Database to avoid requiring sequelize + const Database = { serverSettings: global.ServerSettings } + const mod = require('module') + const originalResolve = mod._resolveFilename + // We need to require the actual file, but it imports Database and Logger + // Use proxyquire-style approach: clear cache and provide stubs + }) + + beforeEach(function () { + // Create a fresh instance for each test by directly constructing the class + // Since the module has complex imports, we test the logic directly + strategy = { + setUserGroup: async function (user, userinfo) { + const groupClaimName = global.ServerSettings.authOpenIDGroupClaim + if (!groupClaimName) return + + if (!userinfo[groupClaimName]) throw new AuthError(`Group claim ${groupClaimName} not found in userinfo`, 401) + + const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) + const rolesInOrderOfPriority = ['admin', 'user', 'guest'] + const groupMap = global.ServerSettings.authOpenIDGroupMap || {} + + let userType = null + + if (Object.keys(groupMap).length > 0) { + for (const role of rolesInOrderOfPriority) { + const mappedGroups = Object.entries(groupMap) + .filter(([, v]) => v === role) + .map(([k]) => k.toLowerCase()) + if (mappedGroups.some((g) => groupsList.includes(g))) { + userType = role + break + } + } + } else { + userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) + } + + if (userType) { + if (user.type === 'root') { + if (userType !== 'admin') { + throw new AuthError(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`, 403) + } else { + return + } + } + if (user.type !== userType) { + user.type = userType + await user.save() + } + } else { + throw new AuthError(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`, 401) + } + } + } + }) + + afterEach(function () { + global.ServerSettings.authOpenIDGroupClaim = '' + global.ServerSettings.authOpenIDGroupMap = {} + }) + + describe('legacy direct name match (empty groupMap)', function () { + it('should assign admin role when group list includes admin', async function () { + global.ServerSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + const userinfo = { groups: ['Admin', 'Users'] } + + await strategy.setUserGroup(user, userinfo) + expect(user.type).to.equal('admin') + expect(user.save.calledOnce).to.be.true + }) + + it('should assign user role when group list includes user but not admin', async function () { + global.ServerSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() } + const userinfo = { groups: ['User', 'Guests'] } + + await strategy.setUserGroup(user, userinfo) + expect(user.type).to.equal('user') + }) + + it('should throw when no valid group found', async function () { + global.ServerSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + const userinfo = { groups: ['unknown-group'] } + + try { + await strategy.setUserGroup(user, userinfo) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.statusCode).to.equal(401) + expect(error.message).to.include('No valid group found') + } + }) + }) + + describe('explicit group mapping', function () { + it('should map custom group names to roles', async function () { + global.ServerSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = { + 'oidc-admins': 'admin', + 'oidc-users': 'user', + 'oidc-guests': 'guest' + } + + const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() } + const userinfo = { groups: ['oidc-users'] } + + await strategy.setUserGroup(user, userinfo) + expect(user.type).to.equal('user') + }) + + it('should prioritize admin over user', async function () { + global.ServerSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = { + 'team-leads': 'admin', + 'developers': 'user' + } + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + const userinfo = { groups: ['developers', 'team-leads'] } + + await strategy.setUserGroup(user, userinfo) + expect(user.type).to.equal('admin') + }) + + it('should be case-insensitive for group matching', async function () { + global.ServerSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = { + 'MyAdmins': 'admin' + } + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + const userinfo = { groups: ['myadmins'] } + + await strategy.setUserGroup(user, userinfo) + expect(user.type).to.equal('admin') + }) + + it('should throw when no mapped group matches', async function () { + global.ServerSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = { + 'admins': 'admin' + } + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + const userinfo = { groups: ['random-group'] } + + try { + await strategy.setUserGroup(user, userinfo) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.statusCode).to.equal(401) + } + }) + }) + + describe('root user protection', function () { + it('should not downgrade root user to non-admin', async function () { + global.ServerSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'root', username: 'root', save: sinon.stub().resolves() } + const userinfo = { groups: ['user'] } + + try { + await strategy.setUserGroup(user, userinfo) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.statusCode).to.equal(403) + expect(error.message).to.include('cannot be downgraded') + } + }) + + it('should allow root user with admin group (no change)', async function () { + global.ServerSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'root', username: 'root', save: sinon.stub().resolves() } + const userinfo = { groups: ['admin'] } + + await strategy.setUserGroup(user, userinfo) + expect(user.type).to.equal('root') // unchanged + expect(user.save.called).to.be.false + }) + }) + + describe('no group claim configured', function () { + it('should do nothing when authOpenIDGroupClaim is empty', async function () { + global.ServerSettings.authOpenIDGroupClaim = '' + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + const userinfo = { groups: ['admin'] } + + await strategy.setUserGroup(user, userinfo) + expect(user.type).to.equal('user') // unchanged + expect(user.save.called).to.be.false + }) + }) + + describe('missing group claim in userinfo', function () { + it('should throw when group claim is not in userinfo', async function () { + global.ServerSettings.authOpenIDGroupClaim = 'groups' + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + const userinfo = { email: 'test@example.com' } + + try { + await strategy.setUserGroup(user, userinfo) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.statusCode).to.equal(401) + expect(error.message).to.include('Group claim groups not found') + } + }) + }) +}) diff --git a/test/server/auth/OidcSettingsSchema.test.js b/test/server/auth/OidcSettingsSchema.test.js new file mode 100644 index 000000000..951477e73 --- /dev/null +++ b/test/server/auth/OidcSettingsSchema.test.js @@ -0,0 +1,155 @@ +const { expect } = require('chai') +const { validateSettings } = require('../../../server/auth/OidcSettingsSchema') + +describe('OidcSettingsSchema - validateSettings', function () { + const validSettings = { + authOpenIDIssuerURL: 'https://auth.example.com', + authOpenIDAuthorizationURL: 'https://auth.example.com/authorize', + authOpenIDTokenURL: 'https://auth.example.com/token', + authOpenIDUserInfoURL: 'https://auth.example.com/userinfo', + authOpenIDJwksURL: 'https://auth.example.com/jwks', + authOpenIDClientID: 'my-client-id', + authOpenIDClientSecret: 'my-client-secret', + authOpenIDTokenSigningAlgorithm: 'RS256' + } + + it('should pass with valid required settings', function () { + const result = validateSettings(validSettings) + expect(result.valid).to.be.true + }) + + it('should fail when required fields are missing', function () { + const result = validateSettings({}) + expect(result.valid).to.be.false + expect(result.errors).to.include('Issuer URL is required') + expect(result.errors).to.include('Client ID is required') + expect(result.errors).to.include('Client Secret is required') + }) + + it('should fail with invalid URL', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDIssuerURL: 'not-a-url' + }) + expect(result.valid).to.be.false + expect(result.errors).to.include('Issuer URL: Invalid URL') + }) + + it('should pass with valid optional fields', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDLogoutURL: 'https://auth.example.com/logout', + authOpenIDButtonText: 'Login with SSO', + authOpenIDAutoLaunch: false, + authOpenIDAutoRegister: true, + authOpenIDScopes: 'openid profile email groups', + authOpenIDGroupClaim: 'groups' + }) + expect(result.valid).to.be.true + }) + + it('should fail with invalid boolean type', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDAutoLaunch: 'yes' + }) + expect(result.valid).to.be.false + expect(result.errors).to.include('Auto Launch: Expected boolean') + }) + + it('should fail with invalid claim name', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDGroupClaim: '123invalid' + }) + expect(result.valid).to.be.false + expect(result.errors).to.include('Group Claim: Invalid claim name') + }) + + it('should pass with valid claim name', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDGroupClaim: 'my-groups_claim' + }) + expect(result.valid).to.be.true + }) + + it('should pass with URN-style claim name (e.g. ZITADEL)', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDGroupClaim: 'urn:zitadel:iam:org:project:roles' + }) + expect(result.valid).to.be.true + }) + + it('should fail with invalid group map values', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDGroupMap: { 'my-group': 'superadmin' } + }) + expect(result.valid).to.be.false + expect(result.errors[0]).to.include('Invalid value "superadmin"') + }) + + it('should pass with valid group map', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDGroupMap: { 'oidc-admins': 'admin', 'oidc-users': 'user', 'oidc-guests': 'guest' } + }) + expect(result.valid).to.be.true + }) + + it('should fail with non-object group map', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDGroupMap: 'not-an-object' + }) + expect(result.valid).to.be.false + expect(result.errors).to.include('Group Mapping: Expected object') + }) + + it('should fail with invalid mobile redirect URIs', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDMobileRedirectURIs: 'not-an-array' + }) + expect(result.valid).to.be.false + expect(result.errors).to.include('Mobile Redirect URIs: Expected array') + }) + + it('should pass with valid redirect URI', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDMobileRedirectURIs: ['audiobookshelf://oauth'] + }) + expect(result.valid).to.be.true + }) + + it('should fail with wildcard URI', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDMobileRedirectURIs: ['*'] + }) + expect(result.valid).to.be.false + expect(result.errors[0]).to.include('Invalid URI') + }) + + it('should reject unknown keys', function () { + const result = validateSettings({ + ...validSettings, + unknownSetting: 'value' + }) + expect(result.valid).to.be.false + expect(result.errors).to.include('Unknown setting: "unknownSetting"') + }) + + it('should skip validation for empty optional fields', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDLogoutURL: '', + authOpenIDGroupClaim: '', + authOpenIDGroupMap: {} + }) + expect(result.valid).to.be.true + }) +}) From 073eff74efc6379614d19a5fb6a4f6585beab8b9 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Thu, 5 Feb 2026 17:55:10 +0100 Subject: [PATCH 02/12] Add OIDC Back-Channel Logout support Implement OIDC Back-Channel Logout 1.0 (RFC). When enabled, the IdP can POST a signed logout_token JWT to invalidate user sessions server-side. - Add BackchannelLogoutHandler: JWT verification via jose, jti replay protection with bounded cache, session destruction by sub or sid - Add oidcSessionId column to sessions table with index for fast lookups - Add backchannel logout route (POST /auth/openid/backchannel-logout) - Notify connected clients via socket to redirect to login page - Add authOpenIDBackchannelLogoutEnabled toggle in schema-driven settings UI - Migration v2.34.0 adds oidcSessionId column and index - Polish settings UI: auto-populate loading state, subfolder dropdown options, KeyValueEditor fixes, localized descriptions via descriptionKey, duplicate key detection, success/error toasts - Localize backchannel logout toast (ToastSessionEndedByProvider) - OidcAuthStrategy tests now use real class via require-cache stubbing --- client/components/app/KeyValueEditor.vue | 26 +- client/components/app/OidcSettings.vue | 32 +- client/layouts/default.vue | 11 + client/pages/config/authentication.vue | 22 +- client/strings/en-us.json | 1 + package.json | 1 + server/Auth.js | 57 +++- server/auth/BackchannelLogoutHandler.js | 148 ++++++++ server/auth/OidcAuthStrategy.js | 93 ++++- server/auth/OidcSettingsSchema.js | 16 +- server/auth/TokenManager.js | 5 +- .../migrations/v2.34.0-backchannel-logout.js | 127 +++++++ server/models/Session.js | 17 +- server/objects/settings/ServerSettings.js | 8 +- .../auth/BackchannelLogoutHandler.test.js | 319 ++++++++++++++++++ test/server/auth/OidcAuthStrategy.test.js | 107 +++--- 16 files changed, 886 insertions(+), 104 deletions(-) create mode 100644 server/auth/BackchannelLogoutHandler.js create mode 100644 server/migrations/v2.34.0-backchannel-logout.js create mode 100644 test/server/auth/BackchannelLogoutHandler.test.js diff --git a/client/components/app/KeyValueEditor.vue b/client/components/app/KeyValueEditor.vue index a50bfba4b..8dda40fcd 100644 --- a/client/components/app/KeyValueEditor.vue +++ b/client/components/app/KeyValueEditor.vue @@ -3,7 +3,7 @@

{{ label }}

- +