diff --git a/client/components/app/KeyValueEditor.vue b/client/components/app/KeyValueEditor.vue new file mode 100644 index 000000000..8dda40fcd --- /dev/null +++ b/client/components/app/KeyValueEditor.vue @@ -0,0 +1,97 @@ + + + diff --git a/client/components/app/OidcSettings.vue b/client/components/app/OidcSettings.vue new file mode 100644 index 000000000..729622601 --- /dev/null +++ b/client/components/app/OidcSettings.vue @@ -0,0 +1,126 @@ + + + diff --git a/client/layouts/default.vue b/client/layouts/default.vue index 75753b214..35e323cd3 100644 --- a/client/layouts/default.vue +++ b/client/layouts/default.vue @@ -364,6 +364,14 @@ export default { adminMessageEvt(message) { this.$toast.info(message) }, + backchannelLogout() { + console.log('[SOCKET] Backchannel logout received from identity provider') + this.$toast.warning(this.$strings.ToastSessionEndedByProvider, { timeout: 5000 }) + // Use a timeout so the toast is visible before redirect + setTimeout(() => { + window.location.replace(`${this.$config.routerBasePath}/login`) + }, 1000) + }, ereaderDevicesUpdated(data) { if (!data?.ereaderDevices) return @@ -474,6 +482,9 @@ export default { this.socket.on('admin_message', this.adminMessageEvt) + // OIDC Back-Channel Logout + this.socket.on('backchannel_logout', this.backchannelLogout) + // Custom metadata provider Listeners this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded) this.socket.on('custom_metadata_provider_removed', this.customMetadataProviderRemoved) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index f31f9ea22..2d4e04f33 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,74 @@ export default { enableOpenIDAuth: false, showCustomLoginMessage: false, savingSettings: false, - openIdSigningAlgorithmsSupportedByIssuer: [], - newAuthSettings: {} + discovering: false, + 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 + this.discovering = true + 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 + // Merge schema overrides (e.g., supported signing algorithms) with existing ones + if (data.schemaOverrides) { + this.openIDSchemaOverrides = { ...this.openIDSchemaOverrides, ...data.schemaOverrides } + } - const pattern = new RegExp('^[a-zA-Z][a-zA-Z0-9_-]*$', 'i') - return pattern.test(claim) + this.$toast.success('Provider endpoints auto-populated') + } 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) + } finally { + this.discovering = false } - 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 +144,63 @@ 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 + } + + // Build subfolder dropdown options from routerBasePath + const basePath = this.$config.routerBasePath + const subfolderOptions = [{ value: '', label: 'None' }] + if (basePath && basePath !== '/') { + subfolderOptions.push({ value: basePath, label: basePath }) + } + this.openIDSchemaOverrides = { + authOpenIDSubfolderForRedirectURLs: { options: subfolderOptions } + } + this.enableLocalAuth = this.authMethods.includes('local') this.enableOpenIDAuth = this.authMethods.includes('openid') this.showCustomLoginMessage = !!this.authSettings.authLoginCustomMessage diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..1075a7e95 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -1134,6 +1134,7 @@ "ToastSessionCloseFailed": "Failed to close session", "ToastSessionDeleteFailed": "Failed to delete session", "ToastSessionDeleteSuccess": "Session deleted", + "ToastSessionEndedByProvider": "Session ended by identity provider", "ToastSleepTimerDone": "Sleep timer done... zZzzZz", "ToastSlugMustChange": "Slug contains invalid characters", "ToastSlugRequired": "Slug is required", diff --git a/package.json b/package.json index 3ee3fb391..d5bfd9e9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "audiobookshelf", - "version": "2.32.1", + "version": "2.34.0", "buildNumber": 1, "description": "Self-hosted audiobook and podcast server", "main": "index.js", @@ -48,6 +48,7 @@ "lru-cache": "^10.0.3", "node-unrar-js": "^2.0.2", "nodemailer": "^6.9.13", + "jose": "^4.15.4", "openid-client": "^5.6.1", "p-throttle": "^4.1.1", "passport": "^0.6.0", diff --git a/server/Auth.js b/server/Auth.js index f63e84460..522cf3c21 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -8,6 +8,7 @@ const Logger = require('./Logger') const TokenManager = require('./auth/TokenManager') const LocalAuthStrategy = require('./auth/LocalAuthStrategy') const OidcAuthStrategy = require('./auth/OidcAuthStrategy') +const BackchannelLogoutHandler = require('./auth/BackchannelLogoutHandler') const RateLimiterFactory = require('./utils/rateLimiterFactory') const { escapeRegExp } = require('./utils') @@ -26,6 +27,7 @@ class Auth { this.tokenManager = new TokenManager() this.localAuthStrategy = new LocalAuthStrategy() this.oidcAuthStrategy = new OidcAuthStrategy() + this.backchannelLogoutHandler = new BackchannelLogoutHandler() } /** @@ -107,6 +109,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 +117,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 +168,8 @@ class Auth { */ unuseAuthStrategy(name) { if (name === 'openid') { - this.oidcAuthStrategy.unuse() + this.oidcAuthStrategy.reload() + this.backchannelLogoutHandler.reset() } else if (name === 'local') { this.localAuthStrategy.unuse() } else { @@ -183,7 +184,8 @@ class Auth { */ useAuthStrategy(name) { if (name === 'openid') { - this.oidcAuthStrategy.init() + this.oidcAuthStrategy.reload() + this.backchannelLogoutHandler.reset() } else if (name === 'local') { this.localAuthStrategy.init() } else { @@ -202,84 +204,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 +243,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,16 +286,24 @@ 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 (authorizationUrlResponse.error) { - return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error) + 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' }) + } } - // 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 + const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req, isMobileFlow, callback) + + if (authorizationUrlResponse.error) { + return res.status(authorizationUrlResponse.status).json({ error: authorizationUrlResponse.error }) } res.redirect(authorizationUrlResponse.authorizationUrl) @@ -377,77 +313,76 @@ 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) => { + // Extract session data before callback (needed for redirect on success) + // These may be null for mobile flow (session not shared with system browser) + const callbackUrl = req.session.oidc?.callbackUrl + let isMobile = !!req.session.oidc?.isMobile - if (!req.session[sessionKey]) { - return res.status(400).send('No session') + try { + const { user, isMobileCallback } = await this.oidcAuthStrategy.handleCallback(req) + + // handleCallback detects mobile via openIdAuthSession Map fallback + if (isMobileCallback) isMobile = true + + // Regenerate session to prevent session fixation (new session ID after login) + await new Promise((resolve, reject) => { + req.session.regenerate((err) => (err ? reject(err) : resolve())) + }) + + // 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 and oidcSessionId in DB + const returnTokens = isMobile + const { accessToken, refreshToken } = await this.tokenManager.createTokensAndSession(user, req, user.openid_id_token, user.openid_session_id) + + 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 { + // Safety net: clear OIDC session data on error paths that occur before session.regenerate() + // (On success, regenerate() already creates a fresh session, making this a no-op) + 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" * @@ -465,7 +400,7 @@ class Auth { const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer) if (openIdIssuerConfig.error) { - return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error) + return res.status(openIdIssuerConfig.status).json({ error: openIdIssuerConfig.error }) } res.json(openIdIssuerConfig) @@ -473,7 +408,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 +416,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 +439,11 @@ 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') + try { + logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, oidcIdToken, authMethod) + } catch (error) { + Logger.error(`[Auth] Failed to get end session URL: ${error.message}`) + } } // Tell the user agent (browser) to redirect to the authentification provider's logout URL @@ -509,6 +452,31 @@ class Auth { } }) }) + + // OIDC Back-Channel Logout endpoint + // Spec: https://openid.net/specs/openid-connect-backchannel-1_0.html + router.post('/auth/openid/backchannel-logout', this.authRateLimiter, async (req, res) => { + if (!global.ServerSettings.authOpenIDBackchannelLogoutEnabled) { + return res.status(501).json({ error: 'not_implemented' }) + } + if (!global.ServerSettings.authActiveAuthMethods.includes('openid') || !Database.serverSettings.isOpenIDAuthSettingsValid) { + return res.status(501).json({ error: 'not_implemented' }) + } + + // Spec Section 2.7: response SHOULD include Cache-Control: no-store + res.set('Cache-Control', 'no-store') + + const logoutToken = req.body?.logout_token + if (!logoutToken || typeof logoutToken !== 'string') { + return res.status(400).json({ error: 'invalid_request' }) + } + + const result = await this.backchannelLogoutHandler.processLogoutToken(logoutToken) + if (result.success) { + return res.sendStatus(200) + } + return res.status(400).json({ error: result.error }) + }) } // #endregion } 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/BackchannelLogoutHandler.js b/server/auth/BackchannelLogoutHandler.js new file mode 100644 index 000000000..1d4865b57 --- /dev/null +++ b/server/auth/BackchannelLogoutHandler.js @@ -0,0 +1,148 @@ +const { createRemoteJWKSet, jwtVerify } = require('jose') +const Logger = require('../Logger') +const Database = require('../Database') +const SocketAuthority = require('../SocketAuthority') + +class BackchannelLogoutHandler { + /** Maximum number of jti entries to keep (bounded by rate limiter: 40 req/10min) */ + static MAX_JTI_CACHE_SIZE = 500 + + constructor() { + /** @type {import('jose').GetKeyFunction|null} */ + this._jwks = null + /** @type {Map} jti -> expiry timestamp for replay protection */ + this._usedJtis = new Map() + } + + /** Reset cached JWKS and jti cache (called when OIDC settings change) */ + reset() { + this._jwks = null + this._usedJtis.clear() + } + + /** + * Check if a jti has already been used (replay protection) + * @param {string} jti + * @returns {boolean} true if the jti is a replay + */ + _isReplayedJti(jti) { + const now = Date.now() + + // Prune expired entries periodically (every check, cheap since Map is small) + for (const [key, expiry] of this._usedJtis) { + if (expiry < now) this._usedJtis.delete(key) + } + + if (this._usedJtis.has(jti)) return true + + // Enforce upper bound to prevent unbounded growth + if (this._usedJtis.size >= BackchannelLogoutHandler.MAX_JTI_CACHE_SIZE) { + // Drop the oldest entry (first inserted in Map iteration order) + const oldestKey = this._usedJtis.keys().next().value + this._usedJtis.delete(oldestKey) + } + + // Store with 5-minute TTL (matches maxTokenAge) + this._usedJtis.set(jti, now + 5 * 60 * 1000) + return false + } + + /** + * Get or create the JWKS key function for JWT verification + * @returns {import('jose').GetKeyFunction} + */ + _getJwks() { + if (!this._jwks) { + const jwksUrl = global.ServerSettings.authOpenIDJwksURL + if (!jwksUrl) throw new Error('JWKS URL not configured') + this._jwks = createRemoteJWKSet(new URL(jwksUrl)) + } + return this._jwks + } + + /** + * Validate and process a backchannel logout token + * @see https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation + * @param {string} logoutToken - the raw JWT logout_token from the IdP + * @returns {Promise<{success: boolean, error?: string}>} + */ + async processLogoutToken(logoutToken) { + try { + // Verify JWT signature, issuer, audience, and max age + const { payload } = await jwtVerify(logoutToken, this._getJwks(), { + issuer: global.ServerSettings.authOpenIDIssuerURL, + audience: global.ServerSettings.authOpenIDClientID, + maxTokenAge: '5m' + }) + + // Check that the events claim contains the backchannel logout event + const events = payload.events + if (!events || typeof events !== 'object' || !('http://schemas.openid.net/event/backchannel-logout' in events)) { + Logger.warn('[BackchannelLogout] Missing or invalid events claim') + return { success: false, error: 'invalid_request' } + } + + // Spec: logout token MUST contain a jti claim + if (!payload.jti) { + Logger.warn('[BackchannelLogout] Missing jti claim') + return { success: false, error: 'invalid_request' } + } + + // Replay protection: reject tokens with previously seen jti + if (this._isReplayedJti(payload.jti)) { + Logger.warn(`[BackchannelLogout] Replayed jti=${payload.jti}`) + return { success: false, error: 'invalid_request' } + } + + // Spec: logout token MUST NOT contain a nonce claim + if (payload.nonce !== undefined) { + Logger.warn('[BackchannelLogout] Token contains nonce claim (not allowed)') + return { success: false, error: 'invalid_request' } + } + + const sub = payload.sub + const sid = payload.sid + + // Spec: token MUST contain sub, sid, or both + if (!sub && !sid) { + Logger.warn('[BackchannelLogout] Token contains neither sub nor sid') + return { success: false, error: 'invalid_request' } + } + + // Destroy sessions and notify clients + if (sid) { + // Session-level logout: destroy sessions matching the OIDC session ID + const destroyedCount = await Database.sessionModel.destroy({ where: { oidcSessionId: sid } }) + if (destroyedCount === 0) { + Logger.warn(`[BackchannelLogout] No sessions found for sid=${sid} (session may predate oidcSessionId migration)`) + } else { + Logger.info(`[BackchannelLogout] Destroyed ${destroyedCount} session(s) for sid=${sid}`) + } + } + + if (sub) { + const user = await Database.userModel.getUserByOpenIDSub(sub) + if (user) { + if (!sid) { + // User-level logout (no sid): destroy all sessions for this user + const destroyedCount = await Database.sessionModel.destroy({ where: { userId: user.id } }) + Logger.info(`[BackchannelLogout] Destroyed ${destroyedCount} session(s) for user=${user.username} (sub=${sub})`) + } + + // Notify connected clients to redirect to login + SocketAuthority.clientEmitter(user.id, 'backchannel_logout', {}) + } else { + // Per spec, unknown sub is not an error — the user may have been deleted + Logger.warn(`[BackchannelLogout] No user found for sub=${sub}`) + } + } + + return { success: true } + } catch (error) { + Logger.error(`[BackchannelLogout] Token validation failed: ${error.message}`) + return { success: false, error: 'invalid_request' } + } + } +} + +module.exports = BackchannelLogoutHandler diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js index 64ab82448..dce29ae35 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,131 @@ 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. + * Also enforces a maximum size to prevent memory exhaustion. + */ + cleanupStaleAuthSessions() { + const maxAge = 10 * 60 * 1000 // 10 minutes + const maxSize = 1000 + const now = Date.now() + for (const [state, session] of this.openIdAuthSession) { + if (now - (session.created_at || 0) > maxAge) { + this.openIdAuthSession.delete(state) + } + } + // If still over limit after TTL cleanup, evict oldest entries + if (this.openIdAuthSession.size > maxSize) { + const entries = [...this.openIdAuthSession.entries()].sort((a, b) => (a[1].created_at || 0) - (b[1].created_at || 0)) + const toRemove = entries.slice(0, this.openIdAuthSession.size - maxSize) + for (const [state] of toRemove) { + 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<{user: import('../models/User'), isMobileCallback: boolean}>} authenticated user and mobile flag + * @throws {AuthError} + */ + async handleCallback(req) { + let sessionData = req.session.oidc + let isMobileCallback = false + + if (!sessionData) { + // Mobile flow: express session is not shared between system browser and app. + // Look up session data from the openIdAuthSession Map using the state parameter. + const state = req.query.state + if (state && this.openIdAuthSession.has(state)) { + const mobileSession = this.openIdAuthSession.get(state) + this.openIdAuthSession.delete(state) + sessionData = { + state: state, + sso_redirect_uri: mobileSession.sso_redirect_uri + } + isMobileCallback = true + } else { + throw new AuthError('No OIDC session found', 400) + } + } + + const client = this.getClient() + + // Mobile: code_verifier comes from query param (client generated PKCE) + // Web: code_verifier comes from session (server generated PKCE) + 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, + nonce: sessionData.nonce, + 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) + + // Extract sid from id_token for backchannel logout support + const idTokenClaims = tokenset.claims() + user.openid_session_id = idTokenClaims?.sid ?? null + + return { user, isMobileCallback } + } + + /** + * 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 !== true) { + 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') + Logger.warn(`[OidcAuth] User lookup failed: ${user.error}`) + throw new AuthError(user.error, 401) } if (!user) { @@ -137,27 +186,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 +234,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 +245,51 @@ 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 rawGroups = userinfo[groupClaimName] + // Normalize group claim formats across providers: + // - Array of strings (Keycloak, Auth0): ["admin", "user"] + // - Single string (some providers with one group): "admin" + // - Object with role keys (Zitadel): { "admin": {...}, "user": {...} } + let groups + if (Array.isArray(rawGroups)) { + groups = rawGroups + } else if (typeof rawGroups === 'string') { + groups = [rawGroups] + } else if (typeof rawGroups === 'object' && rawGroups !== null) { + groups = Object.keys(rawGroups) + } else { + throw new AuthError(`Group claim ${groupClaimName} has unsupported format: ${typeof rawGroups}`, 401) + } + const groupsList = groups.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.`) + Logger.warn(`[OidcAuth] Root user "${user.username}" denied login: IdP group maps to "${userType}", not admin`) + throw new AuthError('Root user cannot be downgraded from admin. Denying login.', 403) } else { // If root user is logging in via OpenID, we will not change the type return @@ -213,7 +302,8 @@ class OidcAuthStrategy { await user.save() } } else { - throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) + Logger.warn(`[OidcAuth] No valid group found in userinfo groups: ${JSON.stringify(userinfo[groupClaimName])}`) + throw new AuthError('No valid group found in userinfo', 401) } } @@ -231,7 +321,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 +364,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 +398,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 +405,9 @@ 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 - // 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 }) + // Mobile flow uses system browser for auth but app's HTTP client for callback, + // so express session is NOT shared. Store all needed data in the openIdAuthSession Map. + this.cleanupStaleAuthSessions() redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() } else { @@ -335,8 +422,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,20 +432,37 @@ class OidcAuthStrategy { } } - req.session[sessionKey] = { - ...req.session[sessionKey], + // Generate nonce to bind id_token to this session (OIDC Core 3.1.2.1) + // Nonce is only used for web flow. Mobile flow relies on PKCE for replay protection, + // and some IdPs don't echo the nonce in the id_token for authorization code flow. + const nonce = isMobileFlow ? undefined : OpenIDClient.generators.nonce() + + if (isMobileFlow) { + // For mobile: store session data in the openIdAuthSession Map (keyed by state) + // because the mobile app's HTTP client has a different express session than the system browser + this.openIdAuthSession.set(state, { + mobile_redirect_uri: req.query.redirect_uri, + sso_redirect_uri: redirectUri, + created_at: Date.now() + }) + } + + // Store OIDC session data in express session (used by web flow callback; + // mobile callback falls back to openIdAuthSession Map above) + req.session.oidc = { state: state, - max_age: strategy._params.max_age, + nonce: nonce, 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, + nonce: nonce, response_type: 'code', scope: this.getScope(), code_challenge: pkceData.code_challenge, @@ -396,18 +498,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, @@ -479,7 +574,7 @@ class OidcAuthStrategy { handleMobileRedirect(req, res) { try { // Extract the state parameter from the request - const { state, code } = req.query + const { state, code, error, error_description } = req.query // Check if the state provided is in our list if (!state || !this.openIdAuthSession.has(state)) { @@ -487,18 +582,29 @@ class OidcAuthStrategy { return res.status(400).send('State parameter mismatch') } - let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri + const sessionEntry = this.openIdAuthSession.get(state) - if (!mobile_redirect_uri) { + if (!sessionEntry.mobile_redirect_uri) { Logger.error('[OidcAuth] No redirect URI') return res.status(400).send('No redirect URI') } - this.openIdAuthSession.delete(state) + // Use URL object to safely append parameters (avoids fragment injection) + const redirectUrl = new URL(sessionEntry.mobile_redirect_uri) + redirectUrl.searchParams.set('state', state) - const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` - // Redirect to the overwrite URI saved in the map - res.redirect(redirectUri) + if (error) { + // IdP returned an error (e.g., user denied consent) — forward to app + redirectUrl.searchParams.set('error', error) + if (error_description) redirectUrl.searchParams.set('error_description', error_description) + // Clean up Map entry since there will be no callback + this.openIdAuthSession.delete(state) + } else { + // Success — forward code to app. Keep Map entry alive for the callback. + redirectUrl.searchParams.set('code', code) + } + + res.redirect(redirectUrl.toString()) } catch (error) { Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) res.status(500).send('Internal Server Error') diff --git a/server/auth/OidcSettingsSchema.js b/server/auth/OidcSettingsSchema.js new file mode 100644 index 000000000..dbf63e9ef --- /dev/null +++ b/server/auth/OidcSettingsSchema.js @@ -0,0 +1,348 @@ +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, descriptionKey: 'LabelOpenIDClaims' }, + { 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', + descriptionKey: 'LabelOpenIDGroupClaimDescription' + }, + { + 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', + descriptionKey: 'LabelOpenIDAdvancedPermsClaimDescription' + }, + + // 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)' + }, + { + key: 'authOpenIDBackchannelLogoutEnabled', + type: 'boolean', + label: 'Back-Channel Logout', + group: 'advanced', + order: 3, + description: 'Enable OIDC Back-Channel Logout. Configure your IdP with the logout URL: {baseURL}/auth/openid/backchannel-logout' + } +] + +/** + * 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, + samplePermissions: 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..c623da1db 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -156,9 +156,11 @@ 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 + * @param {string|null} [oidcSessionId=null] - OIDC session ID (sid claim) for backchannel logout * @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>} */ - async createTokensAndSession(user, req) { + async createTokensAndSession(user, req, oidcIdToken = null, oidcSessionId = null) { const ipAddress = requestIp.getClientIp(req) const userAgent = req.headers['user-agent'] const accessToken = this.generateTempAccessToken(user) @@ -167,7 +169,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, oidcSessionId) return { accessToken, @@ -392,6 +394,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/migrations/v2.34.0-backchannel-logout.js b/server/migrations/v2.34.0-backchannel-logout.js new file mode 100644 index 000000000..117d29595 --- /dev/null +++ b/server/migrations/v2.34.0-backchannel-logout.js @@ -0,0 +1,127 @@ +/** + * @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.34.0' +const migrationName = `${migrationVersion}-backchannel-logout` +const loggerPrefix = `[${migrationVersion} migration]` + +/** + * This migration adds oidcSessionId column to sessions table and + * authOpenIDBackchannelLogoutEnabled to server settings. + * + * @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}`) + + // Add oidcSessionId column to sessions table + if (await queryInterface.tableExists('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + if (!tableDescription.oidcSessionId) { + logger.info(`${loggerPrefix} Adding oidcSessionId column to sessions table`) + await queryInterface.addColumn('sessions', 'oidcSessionId', { + type: queryInterface.sequelize.Sequelize.DataTypes.STRING, + allowNull: true + }) + logger.info(`${loggerPrefix} Added oidcSessionId column to sessions table`) + // Add index for backchannel logout lookups by oidcSessionId + await queryInterface.addIndex('sessions', ['oidcSessionId'], { + name: 'sessions_oidc_session_id' + }) + logger.info(`${loggerPrefix} Added index on oidcSessionId column`) + } else { + logger.info(`${loggerPrefix} oidcSessionId column already exists in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + // Initialize authOpenIDBackchannelLogoutEnabled in server settings + const serverSettings = await getServerSettings(queryInterface, logger) + + if (serverSettings.authOpenIDBackchannelLogoutEnabled === undefined) { + serverSettings.authOpenIDBackchannelLogoutEnabled = false + logger.info(`${loggerPrefix} Initialized authOpenIDBackchannelLogoutEnabled to false`) + } else { + logger.info(`${loggerPrefix} authOpenIDBackchannelLogoutEnabled already exists in server settings`) + } + + await updateServerSettings(queryInterface, logger, serverSettings) + + logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`) +} + +/** + * This migration removes oidcSessionId column from sessions table and + * removes authOpenIDBackchannelLogoutEnabled 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 oidcSessionId column from sessions table + if (await queryInterface.tableExists('sessions')) { + const tableDescription = await queryInterface.describeTable('sessions') + if (tableDescription.oidcSessionId) { + logger.info(`${loggerPrefix} Removing oidcSessionId index and column from sessions table`) + try { + await queryInterface.removeIndex('sessions', 'sessions_oidc_session_id') + } catch { + logger.info(`${loggerPrefix} Index sessions_oidc_session_id did not exist`) + } + await queryInterface.removeColumn('sessions', 'oidcSessionId') + logger.info(`${loggerPrefix} Removed oidcSessionId column from sessions table`) + } else { + logger.info(`${loggerPrefix} oidcSessionId column does not exist in sessions table`) + } + } else { + logger.info(`${loggerPrefix} sessions table does not exist`) + } + + // Remove authOpenIDBackchannelLogoutEnabled from server settings + const serverSettings = await getServerSettings(queryInterface, logger) + if (serverSettings.authOpenIDBackchannelLogoutEnabled !== undefined) { + delete serverSettings.authOpenIDBackchannelLogoutEnabled + await updateServerSettings(queryInterface, logger, serverSettings) + logger.info(`${loggerPrefix} Removed authOpenIDBackchannelLogoutEnabled from server settings`) + } + + 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..1d10aa9ee 100644 --- a/server/models/Session.js +++ b/server/models/Session.js @@ -18,6 +18,10 @@ class Session extends Model { this.userId /** @type {Date} */ this.expiresAt + /** @type {string} */ + this.oidcIdToken + /** @type {string} */ + this.oidcSessionId // Expanded properties @@ -25,8 +29,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, oidcSessionId = null) { + const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken, oidcSessionId }) return session } @@ -66,7 +70,9 @@ class Session extends Model { expiresAt: { type: DataTypes.DATE, allowNull: false - } + }, + oidcIdToken: DataTypes.TEXT, + oidcSessionId: DataTypes.STRING }, { sequelize, diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index a03e17c75..4f9ce2c90 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -82,6 +82,10 @@ class ServerSettings { this.authOpenIDGroupClaim = '' this.authOpenIDAdvancedPermsClaim = '' this.authOpenIDSubfolderForRedirectURLs = undefined + this.authOpenIDScopes = 'openid profile email' + this.authOpenIDGroupMap = {} + this.authOpenIDRequireVerifiedEmail = false + this.authOpenIDBackchannelLogoutEnabled = false if (settings) { this.construct(settings) @@ -146,6 +150,10 @@ 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 + this.authOpenIDBackchannelLogoutEnabled = !!settings.authOpenIDBackchannelLogoutEnabled if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -255,7 +263,11 @@ 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, + authOpenIDBackchannelLogoutEnabled: this.authOpenIDBackchannelLogoutEnabled } } @@ -267,6 +279,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 +296,42 @@ 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, + authOpenIDBackchannelLogoutEnabled: this.authOpenIDBackchannelLogoutEnabled + } + } - 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/BackchannelLogoutHandler.test.js b/test/server/auth/BackchannelLogoutHandler.test.js new file mode 100644 index 000000000..04f2ddf0d --- /dev/null +++ b/test/server/auth/BackchannelLogoutHandler.test.js @@ -0,0 +1,319 @@ +const { expect } = require('chai') +const sinon = require('sinon') + +describe('BackchannelLogoutHandler', function () { + let BackchannelLogoutHandler, handler + let joseStub, DatabaseStub, SocketAuthorityStub + + const BACKCHANNEL_EVENT = 'http://schemas.openid.net/event/backchannel-logout' + + beforeEach(function () { + // Clear require cache so we get fresh stubs each test + delete require.cache[require.resolve('../../../server/auth/BackchannelLogoutHandler')] + + // Stub jose + joseStub = { + createRemoteJWKSet: sinon.stub().returns('jwks-function'), + jwtVerify: sinon.stub() + } + + // Stub Database + DatabaseStub = { + sessionModel: { + destroy: sinon.stub().resolves(1) + }, + userModel: { + getUserByOpenIDSub: sinon.stub() + } + } + + // Stub SocketAuthority + SocketAuthorityStub = { + clientEmitter: sinon.stub() + } + + // Set up global.ServerSettings + global.ServerSettings = { + authOpenIDJwksURL: 'https://idp.example.com/.well-known/jwks.json', + authOpenIDIssuerURL: 'https://idp.example.com', + authOpenIDClientID: 'my-client-id' + } + + // Use proxyquire-style: intercept requires by replacing module cache entries + const Module = require('module') + const originalResolve = Module._resolveFilename + const stubs = { + jose: joseStub, + '../Logger': { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), debug: sinon.stub() }, + '../Database': DatabaseStub, + '../SocketAuthority': SocketAuthorityStub + } + + // Pre-populate the require cache with stubs + const path = require('path') + const handlerPath = require.resolve('../../../server/auth/BackchannelLogoutHandler') + + // We need to stub the dependencies before requiring the handler + // Clear any cached versions of the dependencies + const josePath = require.resolve('jose') + const loggerPath = require.resolve('../../../server/Logger') + const databasePath = require.resolve('../../../server/Database') + const socketPath = require.resolve('../../../server/SocketAuthority') + + // Save original modules + const originalJose = require.cache[josePath] + const originalLogger = require.cache[loggerPath] + const originalDatabase = require.cache[databasePath] + const originalSocket = require.cache[socketPath] + + // Replace with stubs + require.cache[josePath] = { id: josePath, exports: joseStub } + require.cache[loggerPath] = { id: loggerPath, exports: stubs['../Logger'] } + require.cache[databasePath] = { id: databasePath, exports: DatabaseStub } + require.cache[socketPath] = { id: socketPath, exports: SocketAuthorityStub } + + // Now require the handler + BackchannelLogoutHandler = require('../../../server/auth/BackchannelLogoutHandler') + handler = new BackchannelLogoutHandler() + + // Store originals for cleanup + this._originals = { josePath, loggerPath, databasePath, socketPath, originalJose, originalLogger, originalDatabase, originalSocket } + }) + + afterEach(function () { + // Restore original modules + const { josePath, loggerPath, databasePath, socketPath, originalJose, originalLogger, originalDatabase, originalSocket } = this._originals + if (originalJose) require.cache[josePath] = originalJose + else delete require.cache[josePath] + if (originalLogger) require.cache[loggerPath] = originalLogger + else delete require.cache[loggerPath] + if (originalDatabase) require.cache[databasePath] = originalDatabase + else delete require.cache[databasePath] + if (originalSocket) require.cache[socketPath] = originalSocket + else delete require.cache[socketPath] + + delete require.cache[require.resolve('../../../server/auth/BackchannelLogoutHandler')] + + sinon.restore() + }) + + it('should destroy all user sessions for sub-only token', async function () { + const mockUser = { id: 'user-123', username: 'testuser' } + DatabaseStub.userModel.getUserByOpenIDSub.resolves(mockUser) + DatabaseStub.sessionModel.destroy.resolves(2) + + joseStub.jwtVerify.resolves({ + payload: { + jti: 'unique-id-1', + sub: 'oidc-sub-value', + events: { [BACKCHANNEL_EVENT]: {} } + } + }) + + const result = await handler.processLogoutToken('valid.jwt.token') + + expect(result.success).to.be.true + expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true + expect(DatabaseStub.sessionModel.destroy.firstCall.args[0]).to.deep.equal({ where: { userId: 'user-123' } }) + expect(SocketAuthorityStub.clientEmitter.calledOnce).to.be.true + expect(SocketAuthorityStub.clientEmitter.firstCall.args).to.deep.equal(['user-123', 'backchannel_logout', {}]) + }) + + it('should destroy session by sid for sid-only token', async function () { + DatabaseStub.sessionModel.destroy.resolves(1) + + joseStub.jwtVerify.resolves({ + payload: { + jti: 'unique-id-2', + sid: 'session-abc', + events: { [BACKCHANNEL_EVENT]: {} } + } + }) + + const result = await handler.processLogoutToken('valid.jwt.token') + + expect(result.success).to.be.true + expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true + expect(DatabaseStub.sessionModel.destroy.firstCall.args[0]).to.deep.equal({ where: { oidcSessionId: 'session-abc' } }) + // No sub means no user lookup and no socket notification + expect(DatabaseStub.userModel.getUserByOpenIDSub.called).to.be.false + expect(SocketAuthorityStub.clientEmitter.called).to.be.false + }) + + it('should destroy by sid and notify by sub when both present', async function () { + const mockUser = { id: 'user-456', username: 'testuser2' } + DatabaseStub.userModel.getUserByOpenIDSub.resolves(mockUser) + DatabaseStub.sessionModel.destroy.resolves(1) + + joseStub.jwtVerify.resolves({ + payload: { + jti: 'unique-id-3', + sub: 'oidc-sub-value', + sid: 'session-xyz', + events: { [BACKCHANNEL_EVENT]: {} } + } + }) + + const result = await handler.processLogoutToken('valid.jwt.token') + + expect(result.success).to.be.true + // Should destroy by sid (first call) and NOT destroy by userId (sid takes priority) + expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true + expect(DatabaseStub.sessionModel.destroy.firstCall.args[0]).to.deep.equal({ where: { oidcSessionId: 'session-xyz' } }) + // But should still notify the user + expect(SocketAuthorityStub.clientEmitter.calledOnce).to.be.true + expect(SocketAuthorityStub.clientEmitter.firstCall.args[0]).to.equal('user-456') + }) + + it('should return error for invalid JWT signature', async function () { + joseStub.jwtVerify.rejects(new Error('JWS signature verification failed')) + + const result = await handler.processLogoutToken('invalid.jwt.token') + + expect(result.success).to.be.false + expect(result.error).to.equal('invalid_request') + }) + + it('should return error for missing events claim', async function () { + joseStub.jwtVerify.resolves({ + payload: { + sub: 'oidc-sub-value' + // no events + } + }) + + const result = await handler.processLogoutToken('valid.jwt.token') + + expect(result.success).to.be.false + expect(result.error).to.equal('invalid_request') + }) + + it('should return error for wrong events claim value', async function () { + joseStub.jwtVerify.resolves({ + payload: { + sub: 'oidc-sub-value', + events: { 'http://some-other-event': {} } + } + }) + + const result = await handler.processLogoutToken('valid.jwt.token') + + expect(result.success).to.be.false + expect(result.error).to.equal('invalid_request') + }) + + it('should return error when token is missing jti claim', async function () { + joseStub.jwtVerify.resolves({ + payload: { + sub: 'oidc-sub-value', + events: { [BACKCHANNEL_EVENT]: {} } + // no jti + } + }) + + const result = await handler.processLogoutToken('valid.jwt.token') + + expect(result.success).to.be.false + expect(result.error).to.equal('invalid_request') + }) + + it('should return error when token contains nonce', async function () { + joseStub.jwtVerify.resolves({ + payload: { + jti: 'unique-id-4', + sub: 'oidc-sub-value', + nonce: 'some-nonce', + events: { [BACKCHANNEL_EVENT]: {} } + } + }) + + const result = await handler.processLogoutToken('valid.jwt.token') + + expect(result.success).to.be.false + expect(result.error).to.equal('invalid_request') + }) + + it('should return error when token has neither sub nor sid', async function () { + joseStub.jwtVerify.resolves({ + payload: { + jti: 'unique-id-5', + events: { [BACKCHANNEL_EVENT]: {} } + } + }) + + const result = await handler.processLogoutToken('valid.jwt.token') + + expect(result.success).to.be.false + expect(result.error).to.equal('invalid_request') + }) + + it('should return success for unknown sub (no user found)', async function () { + DatabaseStub.userModel.getUserByOpenIDSub.resolves(null) + + joseStub.jwtVerify.resolves({ + payload: { + jti: 'unique-id-6', + sub: 'unknown-sub', + events: { [BACKCHANNEL_EVENT]: {} } + } + }) + + const result = await handler.processLogoutToken('valid.jwt.token') + + // Per spec, unknown sub is not an error + expect(result.success).to.be.true + expect(DatabaseStub.sessionModel.destroy.called).to.be.false + expect(SocketAuthorityStub.clientEmitter.called).to.be.false + }) + + it('should reject replayed jti', async function () { + const mockUser = { id: 'user-123', username: 'testuser' } + DatabaseStub.userModel.getUserByOpenIDSub.resolves(mockUser) + DatabaseStub.sessionModel.destroy.resolves(1) + + joseStub.jwtVerify.resolves({ + payload: { + jti: 'same-jti', + sub: 'oidc-sub-value', + events: { [BACKCHANNEL_EVENT]: {} } + } + }) + + // First call should succeed + const result1 = await handler.processLogoutToken('valid.jwt.token') + expect(result1.success).to.be.true + + // Second call with same jti should be rejected + const result2 = await handler.processLogoutToken('valid.jwt.token') + expect(result2.success).to.be.false + expect(result2.error).to.equal('invalid_request') + }) + + it('should warn when sid destroy matches 0 sessions', async function () { + DatabaseStub.sessionModel.destroy.resolves(0) + + joseStub.jwtVerify.resolves({ + payload: { + jti: 'unique-id-warn', + sid: 'old-session-id', + events: { [BACKCHANNEL_EVENT]: {} } + } + }) + + const result = await handler.processLogoutToken('valid.jwt.token') + + expect(result.success).to.be.true + expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true + }) + + it('should reset cached JWKS and jti cache', function () { + // Call _getJwks to cache + handler._getJwks() + expect(joseStub.createRemoteJWKSet.calledOnce).to.be.true + + // Reset and call again + handler.reset() + handler._getJwks() + expect(joseStub.createRemoteJWKSet.calledTwice).to.be.true + }) +}) diff --git a/test/server/auth/OidcAuthStrategy.test.js b/test/server/auth/OidcAuthStrategy.test.js new file mode 100644 index 000000000..fdae419b4 --- /dev/null +++ b/test/server/auth/OidcAuthStrategy.test.js @@ -0,0 +1,1234 @@ +const { expect } = require('chai') +const sinon = require('sinon') +const OpenIDClient = require('openid-client') +const AuthError = require('../../../server/auth/AuthError') + +// Test the real OidcAuthStrategy by stubbing its module-level dependencies +describe('OidcAuthStrategy', function () { + let OidcAuthStrategy, strategy + let DatabaseStub + + beforeEach(function () { + // Clear require cache so we get fresh stubs each test + delete require.cache[require.resolve('../../../server/auth/OidcAuthStrategy')] + + global.ServerSettings = { + authOpenIDGroupClaim: '', + authOpenIDGroupMap: {}, + authOpenIDScopes: 'openid profile email', + isOpenIDAuthSettingsValid: false, + authOpenIDMobileRedirectURIs: ['audiobookshelf://oauth'], + authOpenIDAutoRegister: false, + authOpenIDRequireVerifiedEmail: false, + authOpenIDAdvancedPermsClaim: '' + } + global.RouterBasePath = '/audiobookshelf' + + DatabaseStub = { + serverSettings: global.ServerSettings, + userModel: { + findUserFromOpenIdUserInfo: sinon.stub(), + createUserFromOpenIdUserInfo: sinon.stub() + } + } + + const LoggerStub = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), debug: sinon.stub() } + + // Stub dependencies in require cache + const databasePath = require.resolve('../../../server/Database') + const loggerPath = require.resolve('../../../server/Logger') + + // Save originals for cleanup + this._originals = { + databasePath, + loggerPath, + originalDatabase: require.cache[databasePath], + originalLogger: require.cache[loggerPath] + } + + // Replace with stubs + require.cache[databasePath] = { id: databasePath, exports: DatabaseStub } + require.cache[loggerPath] = { id: loggerPath, exports: LoggerStub } + + // Now require the real class + OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy') + strategy = new OidcAuthStrategy() + }) + + afterEach(function () { + const { databasePath, loggerPath, originalDatabase, originalLogger } = this._originals + if (originalDatabase) require.cache[databasePath] = originalDatabase + else delete require.cache[databasePath] + if (originalLogger) require.cache[loggerPath] = originalLogger + else delete require.cache[loggerPath] + + delete require.cache[require.resolve('../../../server/auth/OidcAuthStrategy')] + delete global.RouterBasePath + sinon.restore() + }) + + // ── setUserGroup ───────────────────────────────────────────────────── + + describe('setUserGroup', function () { + describe('legacy direct name match (empty groupMap)', function () { + it('should assign admin role when group list includes admin', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['Admin', 'Users'] }) + 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 () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['User', 'Guests'] }) + expect(user.type).to.equal('user') + }) + + it('should throw when no valid group found', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + try { + await strategy.setUserGroup(user, { groups: ['unknown-group'] }) + 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 () { + DatabaseStub.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() } + await strategy.setUserGroup(user, { groups: ['oidc-users'] }) + expect(user.type).to.equal('user') + }) + + it('should prioritize admin over user', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = { 'team-leads': 'admin', 'developers': 'user' } + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['developers', 'team-leads'] }) + expect(user.type).to.equal('admin') + }) + + it('should be case-insensitive for group matching', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = { 'MyAdmins': 'admin' } + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['myadmins'] }) + expect(user.type).to.equal('admin') + }) + + it('should throw when no mapped group matches', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = { 'admins': 'admin' } + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + try { + await strategy.setUserGroup(user, { groups: ['random-group'] }) + 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 () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'root', username: 'root', save: sinon.stub().resolves() } + try { + await strategy.setUserGroup(user, { groups: ['user'] }) + 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 () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'root', username: 'root', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['admin'] }) + expect(user.type).to.equal('root') + expect(user.save.called).to.be.false + }) + }) + + describe('no group claim configured', function () { + it('should do nothing when authOpenIDGroupClaim is empty', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = '' + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['admin'] }) + expect(user.type).to.equal('user') + expect(user.save.called).to.be.false + }) + }) + + describe('single string claim', function () { + it('should handle a single string group value', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: 'admin' }) + expect(user.type).to.equal('admin') + }) + }) + + describe('object-shaped claims (e.g. Zitadel)', function () { + it('should extract group names from object keys with legacy match', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'urn:zitadel:iam:org:project:roles' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { + 'urn:zitadel:iam:org:project:roles': { + admin: { '359584706087354371': 'website.de' }, + user: { '359584706087354371': 'website.de' } + } + }) + expect(user.type).to.equal('admin') + }) + + it('should extract group names from object keys with explicit mapping', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'urn:zitadel:iam:org:project:roles' + global.ServerSettings.authOpenIDGroupMap = { 'zitadel-users': 'user', 'zitadel-admins': 'admin' } + + const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { + 'urn:zitadel:iam:org:project:roles': { + 'zitadel-users': { '123': 'example.com' } + } + }) + expect(user.type).to.equal('user') + }) + + it('should throw when no matching group in object keys', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'roles' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + try { + await strategy.setUserGroup(user, { + roles: { 'some-unknown-role': { '123': 'example.com' } } + }) + 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('missing group claim in userinfo', function () { + it('should throw when group claim is not in userinfo', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + try { + await strategy.setUserGroup(user, { email: 'test@example.com' }) + 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') + } + }) + }) + }) + + // ── validateGroupClaim ─────────────────────────────────────────────── + + describe('validateGroupClaim', function () { + it('should return true when no group claim is configured', function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = '' + expect(strategy.validateGroupClaim({ groups: ['admin'] })).to.be.true + expect(strategy.validateGroupClaim({})).to.be.true + }) + + it('should return true when group claim exists in userinfo', function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + expect(strategy.validateGroupClaim({ groups: ['admin'] })).to.be.true + }) + + it('should return false when group claim is missing from userinfo', function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + expect(strategy.validateGroupClaim({ email: 'test@example.com' })).to.be.false + }) + + it('should return true when group claim is empty array', function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + // Empty array is falsy for the `!userinfo[groupClaimName]` check? No, [] is truthy. + // Actually [] is truthy in JS, so this should return true + expect(strategy.validateGroupClaim({ groups: [] })).to.be.true + }) + + it('should work with custom claim names', function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'urn:zitadel:iam:org:project:roles' + expect(strategy.validateGroupClaim({ 'urn:zitadel:iam:org:project:roles': ['admin'] })).to.be.true + expect(strategy.validateGroupClaim({ groups: ['admin'] })).to.be.false + }) + }) + + // ── isValidRedirectUri ─────────────────────────────────────────────── + + describe('isValidRedirectUri', function () { + it('should accept URIs in the whitelist', function () { + DatabaseStub.serverSettings.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth', 'myapp://callback'] + expect(strategy.isValidRedirectUri('audiobookshelf://oauth')).to.be.true + expect(strategy.isValidRedirectUri('myapp://callback')).to.be.true + }) + + it('should reject URIs not in the whitelist', function () { + DatabaseStub.serverSettings.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] + expect(strategy.isValidRedirectUri('evil://callback')).to.be.false + expect(strategy.isValidRedirectUri('audiobookshelf://other')).to.be.false + }) + + it('should reject empty string', function () { + DatabaseStub.serverSettings.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] + expect(strategy.isValidRedirectUri('')).to.be.false + }) + + it('should handle empty whitelist', function () { + DatabaseStub.serverSettings.authOpenIDMobileRedirectURIs = [] + expect(strategy.isValidRedirectUri('audiobookshelf://oauth')).to.be.false + }) + + it('should require exact match (no partial matching)', function () { + DatabaseStub.serverSettings.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] + expect(strategy.isValidRedirectUri('audiobookshelf://oauth/extra')).to.be.false + expect(strategy.isValidRedirectUri('audiobookshelf://oaut')).to.be.false + }) + }) + + // ── isValidWebCallbackUrl ──────────────────────────────────────────── + + describe('isValidWebCallbackUrl', function () { + function makeReq(host, secure, xfp) { + return { + secure: !!secure, + get: (header) => { + if (header === 'host') return host + if (header === 'x-forwarded-proto') return xfp || '' + return '' + } + } + } + + it('should accept relative URL starting with router base path', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com') + expect(strategy.isValidWebCallbackUrl('/audiobookshelf/login', req)).to.be.true + expect(strategy.isValidWebCallbackUrl('/audiobookshelf/', req)).to.be.true + }) + + it('should reject relative URL outside router base path', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com') + expect(strategy.isValidWebCallbackUrl('/evil/path', req)).to.be.false + expect(strategy.isValidWebCallbackUrl('/audiobookshel/typo', req)).to.be.false + }) + + it('should accept same-origin absolute URL with matching path', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com:3333', false) + expect(strategy.isValidWebCallbackUrl('http://example.com:3333/audiobookshelf/login', req)).to.be.true + }) + + it('should reject absolute URL with different host', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com', false) + expect(strategy.isValidWebCallbackUrl('http://evil.com/audiobookshelf/login', req)).to.be.false + }) + + it('should reject absolute URL with different protocol', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com', true) + expect(strategy.isValidWebCallbackUrl('http://example.com/audiobookshelf/login', req)).to.be.false + }) + + it('should accept https URL when behind reverse proxy (x-forwarded-proto)', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com', false, 'https') + expect(strategy.isValidWebCallbackUrl('https://example.com/audiobookshelf/login', req)).to.be.true + }) + + it('should handle multiple x-forwarded-proto values', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com', false, 'https, http') + expect(strategy.isValidWebCallbackUrl('https://example.com/audiobookshelf/login', req)).to.be.true + }) + + it('should reject same-origin URL with path outside router base', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com', false) + expect(strategy.isValidWebCallbackUrl('http://example.com/evil/path', req)).to.be.false + }) + + it('should reject null or empty callback URL', function () { + const req = makeReq('example.com') + expect(strategy.isValidWebCallbackUrl(null, req)).to.be.false + expect(strategy.isValidWebCallbackUrl('', req)).to.be.false + }) + + it('should reject malformed URLs gracefully', function () { + const req = makeReq('example.com') + expect(strategy.isValidWebCallbackUrl('not-a-valid-url', req)).to.be.false + }) + + it('should work with root router base path', function () { + global.RouterBasePath = '' + const req = makeReq('example.com', false) + expect(strategy.isValidWebCallbackUrl('http://example.com/login', req)).to.be.true + }) + }) + + // ── updateUserPermissions ──────────────────────────────────────────── + + describe('updateUserPermissions', function () { + it('should do nothing when no advanced permissions claim is configured', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = '' + const user = { type: 'user', username: 'testuser', updatePermissionsFromExternalJSON: sinon.stub() } + await strategy.updateUserPermissions(user, { perms: '{}' }) + expect(user.updatePermissionsFromExternalJSON.called).to.be.false + }) + + it('should skip admin users', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = 'abs_perms' + const user = { type: 'admin', username: 'adminuser', updatePermissionsFromExternalJSON: sinon.stub() } + await strategy.updateUserPermissions(user, { abs_perms: { canUpload: true } }) + expect(user.updatePermissionsFromExternalJSON.called).to.be.false + }) + + it('should skip root users', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = 'abs_perms' + const user = { type: 'root', username: 'root', updatePermissionsFromExternalJSON: sinon.stub() } + await strategy.updateUserPermissions(user, { abs_perms: { canUpload: true } }) + expect(user.updatePermissionsFromExternalJSON.called).to.be.false + }) + + it('should update permissions for non-admin user', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = 'abs_perms' + const permsData = { canUpload: true, canDelete: false } + const user = { type: 'user', username: 'testuser', updatePermissionsFromExternalJSON: sinon.stub().resolves(true) } + + await strategy.updateUserPermissions(user, { abs_perms: permsData }) + expect(user.updatePermissionsFromExternalJSON.calledOnce).to.be.true + expect(user.updatePermissionsFromExternalJSON.firstCall.args[0]).to.deep.equal(permsData) + }) + + it('should throw when claim is configured but missing from userinfo', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = 'abs_perms' + const user = { type: 'user', username: 'testuser', updatePermissionsFromExternalJSON: sinon.stub() } + + try { + await strategy.updateUserPermissions(user, { email: 'test@example.com' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.statusCode).to.equal(401) + expect(error.message).to.include('abs_perms') + } + }) + + it('should work for guest users', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = 'abs_perms' + const user = { type: 'guest', username: 'guestuser', updatePermissionsFromExternalJSON: sinon.stub().resolves(false) } + + await strategy.updateUserPermissions(user, { abs_perms: { canUpload: false } }) + expect(user.updatePermissionsFromExternalJSON.calledOnce).to.be.true + }) + }) + + // ── verifyUser ─────────────────────────────────────────────────────── + + describe('verifyUser', function () { + function makeUser(overrides = {}) { + return { + id: 'user-123', + username: 'testuser', + type: 'user', + isActive: true, + save: sinon.stub().resolves(), + destroy: sinon.stub().resolves(), + updatePermissionsFromExternalJSON: sinon.stub().resolves(false), + ...overrides + } + } + + it('should return existing user on successful verification', async function () { + const existingUser = makeUser() + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(existingUser) + + const tokenset = { id_token: 'test-id-token' } + const userinfo = { sub: 'oidc-sub-123', email: 'test@example.com' } + + const result = await strategy.verifyUser(tokenset, userinfo) + expect(result).to.equal(existingUser) + expect(result.openid_id_token).to.equal('test-id-token') + expect(DatabaseStub.userModel.findUserFromOpenIdUserInfo.calledOnce).to.be.true + }) + + it('should throw when userinfo has no sub', async function () { + try { + await strategy.verifyUser({ id_token: 'tok' }, { email: 'test@example.com' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('no sub') + } + }) + + it('should throw when group claim validation fails', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1', email: 'test@example.com' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('Group claim') + } + }) + + it('should throw when email_verified is false and enforcement is on', async function () { + global.ServerSettings.authOpenIDRequireVerifiedEmail = true + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(makeUser()) + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1', email: 'test@example.com', email_verified: false }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('not verified') + } + }) + + it('should allow login when email_verified is true and enforcement is on', async function () { + global.ServerSettings.authOpenIDRequireVerifiedEmail = true + const user = makeUser() + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(user) + + const result = await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1', email: 'a@b.com', email_verified: true }) + expect(result).to.equal(user) + }) + + it('should reject login when email_verified is missing and enforcement is on', async function () { + global.ServerSettings.authOpenIDRequireVerifiedEmail = true + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1', email: 'a@b.com' }) + expect.fail('should have thrown') + } catch (err) { + expect(err.message).to.equal('Email is not verified') + expect(err.statusCode).to.equal(401) + } + }) + + it('should auto-register new user when enabled', async function () { + global.ServerSettings.authOpenIDAutoRegister = true + const newUser = makeUser({ username: 'newuser' }) + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(null) + DatabaseStub.userModel.createUserFromOpenIdUserInfo.resolves(newUser) + + const result = await strategy.verifyUser({ id_token: 'tok' }, { sub: 'new-sub', email: 'new@example.com' }) + expect(result).to.equal(newUser) + expect(DatabaseStub.userModel.createUserFromOpenIdUserInfo.calledOnce).to.be.true + }) + + it('should throw when user not found and auto-register is disabled', async function () { + global.ServerSettings.authOpenIDAutoRegister = false + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(null) + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'unknown-sub' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('auto-register is disabled') + } + }) + + it('should throw when user is inactive', async function () { + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(makeUser({ isActive: false })) + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('not active') + } + }) + + it('should throw when findUserFromOpenIdUserInfo returns error object', async function () { + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves({ error: 'already linked' }) + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('already linked') + } + }) + + it('should destroy new user if setUserGroup fails', async function () { + global.ServerSettings.authOpenIDAutoRegister = true + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + const newUser = makeUser({ username: 'newuser' }) + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(null) + DatabaseStub.userModel.createUserFromOpenIdUserInfo.resolves(newUser) + + try { + // groups claim present but no valid role found + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'new-sub', groups: ['unknown-group'] }) + expect.fail('Should have thrown') + } catch (error) { + expect(newUser.destroy.calledOnce).to.be.true + } + }) + + it('should not destroy existing user on error', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + const existingUser = makeUser() + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(existingUser) + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1', groups: ['unknown-group'] }) + expect.fail('Should have thrown') + } catch (error) { + expect(existingUser.destroy.called).to.be.false + } + }) + }) + + // ── handleCallback ─────────────────────────────────────────────────── + + describe('handleCallback', function () { + let mockClient + + beforeEach(function () { + mockClient = { + callbackParams: sinon.stub().returns({ code: 'auth-code' }), + callback: sinon.stub(), + userinfo: sinon.stub() + } + sinon.stub(strategy, 'getClient').returns(mockClient) + }) + + it('should handle web flow using express session data', async function () { + const mockUser = { id: 'user-1', username: 'test' } + const mockTokenset = { + access_token: 'at-123', + id_token: 'idt-123', + claims: sinon.stub().returns({ sid: 'session-456' }) + } + mockClient.callback.resolves(mockTokenset) + mockClient.userinfo.resolves({ sub: 'sub-1' }) + sinon.stub(strategy, 'verifyUser').resolves(mockUser) + + const req = { + session: { + oidc: { + state: 'web-state', + nonce: 'web-nonce', + sso_redirect_uri: 'http://localhost/auth/openid/callback', + code_verifier: 'web-verifier' + } + }, + query: {} + } + + const result = await strategy.handleCallback(req) + expect(result.user).to.equal(mockUser) + expect(result.isMobileCallback).to.be.false + + // Verify token exchange was called with correct parameters + expect(mockClient.callback.calledOnce).to.be.true + const [redirectUri, , checks] = mockClient.callback.firstCall.args + expect(redirectUri).to.equal('http://localhost/auth/openid/callback') + expect(checks.state).to.equal('web-state') + expect(checks.nonce).to.equal('web-nonce') + expect(checks.code_verifier).to.equal('web-verifier') + expect(checks.response_type).to.equal('code') + }) + + it('should fall back to openIdAuthSession Map for mobile flow', async function () { + const mockUser = { id: 'user-1', username: 'test' } + const mockTokenset = { + access_token: 'at-123', + id_token: 'idt-123', + claims: sinon.stub().returns({}) + } + mockClient.callback.resolves(mockTokenset) + mockClient.userinfo.resolves({ sub: 'sub-1' }) + sinon.stub(strategy, 'verifyUser').resolves(mockUser) + + // Pre-populate Map as if getAuthorizationUrl stored mobile session + // Note: mobile flow does not use nonce (relies on PKCE instead) + strategy.openIdAuthSession.set('mobile-state', { + sso_redirect_uri: 'http://localhost/auth/openid/mobile-redirect', + mobile_redirect_uri: 'audiobookshelf://oauth' + }) + + const req = { + session: {}, // No oidc session (mobile system browser != app) + query: { state: 'mobile-state', code_verifier: 'mobile-verifier' } + } + + const result = await strategy.handleCallback(req) + expect(result.isMobileCallback).to.be.true + expect(result.user).to.equal(mockUser) + + // Should delete the Map entry after use + expect(strategy.openIdAuthSession.has('mobile-state')).to.be.false + + // Should use code_verifier from query; nonce is undefined for mobile flow + const [, , checks] = mockClient.callback.firstCall.args + expect(checks.nonce).to.be.undefined + expect(checks.code_verifier).to.equal('mobile-verifier') + }) + + it('should throw AuthError when no session and no matching state', async function () { + const req = { + session: {}, + query: { state: 'unknown-state' } + } + + try { + await strategy.handleCallback(req) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.statusCode).to.equal(400) + expect(error.message).to.include('No OIDC session found') + } + }) + + it('should throw when no session and no state parameter at all', async function () { + const req = { + session: {}, + query: {} + } + + try { + await strategy.handleCallback(req) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.statusCode).to.equal(400) + } + }) + + it('should extract sid from id_token claims for backchannel logout', async function () { + const mockUser = { id: 'user-1' } + const mockTokenset = { + access_token: 'at-123', + id_token: 'idt-123', + claims: sinon.stub().returns({ sid: 'oidc-sid-789' }) + } + mockClient.callback.resolves(mockTokenset) + mockClient.userinfo.resolves({ sub: 'sub-1' }) + sinon.stub(strategy, 'verifyUser').resolves(mockUser) + + const req = { + session: { oidc: { state: 's', nonce: 'n', sso_redirect_uri: 'http://x', code_verifier: 'v' } }, + query: {} + } + + const result = await strategy.handleCallback(req) + expect(result.user.openid_session_id).to.equal('oidc-sid-789') + }) + + it('should set openid_session_id to null when no sid in claims', async function () { + const mockUser = { id: 'user-1' } + const mockTokenset = { + access_token: 'at-123', + id_token: 'idt-123', + claims: sinon.stub().returns({}) + } + mockClient.callback.resolves(mockTokenset) + mockClient.userinfo.resolves({ sub: 'sub-1' }) + sinon.stub(strategy, 'verifyUser').resolves(mockUser) + + const req = { + session: { oidc: { state: 's', nonce: 'n', sso_redirect_uri: 'http://x', code_verifier: 'v' } }, + query: {} + } + + const result = await strategy.handleCallback(req) + expect(result.user.openid_session_id).to.be.null + }) + + + it('should call userinfo with the access token', async function () { + const mockUser = { id: 'user-1' } + const mockTokenset = { access_token: 'the-access-token', id_token: 'idt', claims: () => ({}) } + mockClient.callback.resolves(mockTokenset) + mockClient.userinfo.resolves({ sub: 'sub-1' }) + sinon.stub(strategy, 'verifyUser').resolves(mockUser) + + const req = { + session: { oidc: { state: 's', nonce: 'n', sso_redirect_uri: 'http://x', code_verifier: 'v' } }, + query: {} + } + + await strategy.handleCallback(req) + expect(mockClient.userinfo.calledOnceWith('the-access-token')).to.be.true + }) + + it('should propagate errors from token exchange', async function () { + mockClient.callback.rejects(new Error('invalid_grant')) + + const req = { + session: { oidc: { state: 's', nonce: 'n', sso_redirect_uri: 'http://x', code_verifier: 'v' } }, + query: {} + } + + try { + await strategy.handleCallback(req) + expect.fail('Should have thrown') + } catch (error) { + expect(error.message).to.include('invalid_grant') + } + }) + + it('should propagate errors from verifyUser', async function () { + const mockTokenset = { access_token: 'at', id_token: 'idt', claims: () => ({}) } + mockClient.callback.resolves(mockTokenset) + mockClient.userinfo.resolves({ sub: 'sub-1' }) + sinon.stub(strategy, 'verifyUser').rejects(new AuthError('Group claim not found', 401)) + + const req = { + session: { oidc: { state: 's', nonce: 'n', sso_redirect_uri: 'http://x', code_verifier: 'v' } }, + query: {} + } + + try { + await strategy.handleCallback(req) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('Group claim not found') + } + }) + }) + + // ── getAuthorizationUrl ────────────────────────────────────────────── + + describe('getAuthorizationUrl', function () { + let mockClient + + beforeEach(function () { + mockClient = { + authorizationUrl: sinon.stub().returns('https://idp.example.com/authorize?params') + } + sinon.stub(strategy, 'getClient').returns(mockClient) + sinon.stub(OpenIDClient.generators, 'random').returns('mock-state') + sinon.stub(OpenIDClient.generators, 'nonce').returns('mock-nonce') + global.ServerSettings.authOpenIDSubfolderForRedirectURLs = '' + }) + + function makeReq(overrides = {}) { + const req = { + secure: false, + get: (header) => { + if (header === 'host') return 'example.com:3333' + if (header === 'x-forwarded-proto') return '' + return '' + }, + query: {}, + session: {}, + ...overrides + } + return req + } + + it('should generate authorization URL for web flow', function () { + sinon.stub(strategy, 'generatePkce').returns({ + code_challenge: 'challenge-123', + code_challenge_method: 'S256', + code_verifier: 'verifier-123' + }) + + const result = strategy.getAuthorizationUrl(makeReq(), false, '/login') + + expect(result.authorizationUrl).to.equal('https://idp.example.com/authorize?params') + expect(result.isMobileFlow).to.be.false + expect(mockClient.authorizationUrl.calledOnce).to.be.true + + const params = mockClient.authorizationUrl.firstCall.args[0] + expect(params.redirect_uri).to.equal('http://example.com:3333/auth/openid/callback') + expect(params.state).to.equal('mock-state') + expect(params.nonce).to.equal('mock-nonce') + expect(params.response_type).to.equal('code') + expect(params.code_challenge).to.equal('challenge-123') + expect(params.code_challenge_method).to.equal('S256') + }) + + it('should store OIDC data in express session for web flow', function () { + sinon.stub(strategy, 'generatePkce').returns({ + code_challenge: 'c', code_challenge_method: 'S256', code_verifier: 'v' + }) + + const req = makeReq() + strategy.getAuthorizationUrl(req, false, '/login') + + expect(req.session.oidc).to.deep.include({ + state: 'mock-state', + nonce: 'mock-nonce', + isMobile: false, + code_verifier: 'v', + callbackUrl: '/login' + }) + }) + + it('should reject state parameter on web flow', function () { + const req = makeReq({ query: { state: 'evil-state' } }) + sinon.stub(strategy, 'generatePkce').returns({ + code_challenge: 'c', code_challenge_method: 'S256', code_verifier: 'v' + }) + + const result = strategy.getAuthorizationUrl(req, false, '/login') + expect(result.status).to.equal(400) + expect(result.error).to.include('not allowed on web flow') + }) + + it('should reject invalid response_type', function () { + const req = makeReq({ query: { response_type: 'token' } }) + const result = strategy.getAuthorizationUrl(req, false, '/login') + expect(result.status).to.equal(400) + expect(result.error).to.include('only code supported') + }) + + it('should generate authorization URL for mobile flow', function () { + sinon.stub(strategy, 'generatePkce').returns({ + code_challenge: 'mob-c', code_challenge_method: 'S256' + }) + sinon.stub(strategy, 'isValidRedirectUri').returns(true) + sinon.stub(strategy, 'cleanupStaleAuthSessions') + + const req = makeReq({ + query: { redirect_uri: 'audiobookshelf://oauth', state: 'mobile-state' } + }) + + const result = strategy.getAuthorizationUrl(req, true, null) + + expect(result.isMobileFlow).to.be.true + const params = mockClient.authorizationUrl.firstCall.args[0] + expect(params.redirect_uri).to.equal('http://example.com:3333/auth/openid/mobile-redirect') + // Mobile uses client-supplied state + expect(params.state).to.equal('mobile-state') + }) + + it('should store mobile session in openIdAuthSession Map', function () { + sinon.stub(strategy, 'generatePkce').returns({ + code_challenge: 'c', code_challenge_method: 'S256' + }) + sinon.stub(strategy, 'isValidRedirectUri').returns(true) + sinon.stub(strategy, 'cleanupStaleAuthSessions') + + const req = makeReq({ + query: { redirect_uri: 'audiobookshelf://oauth', state: 'mob-state' } + }) + + strategy.getAuthorizationUrl(req, true, null) + + expect(strategy.openIdAuthSession.has('mob-state')).to.be.true + const stored = strategy.openIdAuthSession.get('mob-state') + expect(stored.mobile_redirect_uri).to.equal('audiobookshelf://oauth') + expect(stored.nonce).to.be.undefined + expect(stored.sso_redirect_uri).to.include('/auth/openid/mobile-redirect') + }) + + it('should reject invalid mobile redirect_uri', function () { + sinon.stub(strategy, 'isValidRedirectUri').returns(false) + sinon.stub(strategy, 'cleanupStaleAuthSessions') + + const req = makeReq({ + query: { redirect_uri: 'evil://callback' } + }) + + const result = strategy.getAuthorizationUrl(req, true, null) + expect(result.status).to.equal(400) + expect(result.error).to.include('Invalid redirect_uri') + }) + + it('should return error when PKCE fails for mobile', function () { + sinon.stub(strategy, 'generatePkce').returns({ error: 'code_challenge required for mobile flow (PKCE)' }) + sinon.stub(strategy, 'isValidRedirectUri').returns(true) + sinon.stub(strategy, 'cleanupStaleAuthSessions') + + const req = makeReq({ + query: { redirect_uri: 'audiobookshelf://oauth', state: 's' } + }) + + const result = strategy.getAuthorizationUrl(req, true, null) + expect(result.status).to.equal(400) + expect(result.error).to.include('code_challenge required') + }) + + it('should use subfolder in redirect URI when configured', function () { + global.ServerSettings.authOpenIDSubfolderForRedirectURLs = '/audiobookshelf' + sinon.stub(strategy, 'generatePkce').returns({ + code_challenge: 'c', code_challenge_method: 'S256', code_verifier: 'v' + }) + + strategy.getAuthorizationUrl(makeReq(), false, '/login') + + const params = mockClient.authorizationUrl.firstCall.args[0] + expect(params.redirect_uri).to.equal('http://example.com:3333/audiobookshelf/auth/openid/callback') + }) + + it('should use https when request is secure', function () { + sinon.stub(strategy, 'generatePkce').returns({ + code_challenge: 'c', code_challenge_method: 'S256', code_verifier: 'v' + }) + + const req = makeReq({ secure: true }) + strategy.getAuthorizationUrl(req, false, '/login') + + const params = mockClient.authorizationUrl.firstCall.args[0] + expect(params.redirect_uri).to.match(/^https:/) + }) + + it('should use https when x-forwarded-proto is https', function () { + sinon.stub(strategy, 'generatePkce').returns({ + code_challenge: 'c', code_challenge_method: 'S256', code_verifier: 'v' + }) + + const req = { + secure: false, + get: (h) => { + if (h === 'host') return 'example.com' + if (h === 'x-forwarded-proto') return 'https' + return '' + }, + query: {}, + session: {} + } + strategy.getAuthorizationUrl(req, false, '/login') + + const params = mockClient.authorizationUrl.firstCall.args[0] + expect(params.redirect_uri).to.match(/^https:/) + }) + + it('should generate state for mobile flow when client does not supply one', function () { + sinon.stub(strategy, 'generatePkce').returns({ + code_challenge: 'c', code_challenge_method: 'S256' + }) + sinon.stub(strategy, 'isValidRedirectUri').returns(true) + sinon.stub(strategy, 'cleanupStaleAuthSessions') + + const req = makeReq({ + query: { redirect_uri: 'audiobookshelf://oauth' } + }) + + strategy.getAuthorizationUrl(req, true, null) + + // When no state in query, generators.random() is called + expect(OpenIDClient.generators.random.calledOnce).to.be.true + const params = mockClient.authorizationUrl.firstCall.args[0] + expect(params.state).to.equal('mock-state') + }) + + it('should use scope from getScope()', function () { + global.ServerSettings.authOpenIDScopes = 'openid profile email groups' + sinon.stub(strategy, 'generatePkce').returns({ + code_challenge: 'c', code_challenge_method: 'S256', code_verifier: 'v' + }) + + strategy.getAuthorizationUrl(makeReq(), false, '/login') + + const params = mockClient.authorizationUrl.firstCall.args[0] + expect(params.scope).to.equal('openid profile email groups') + }) + }) + + // ── generatePkce ───────────────────────────────────────────────────── + + describe('generatePkce', function () { + it('should generate PKCE for web flow', function () { + sinon.stub(OpenIDClient.generators, 'codeVerifier').returns('gen-verifier') + sinon.stub(OpenIDClient.generators, 'codeChallenge').returns('gen-challenge') + + const result = strategy.generatePkce({ query: {} }, false) + expect(result.code_verifier).to.equal('gen-verifier') + expect(result.code_challenge).to.equal('gen-challenge') + expect(result.code_challenge_method).to.equal('S256') + }) + + it('should use client-provided code_challenge for mobile', function () { + const req = { query: { code_challenge: 'client-challenge', code_challenge_method: 'S256' } } + const result = strategy.generatePkce(req, true) + + expect(result.code_challenge).to.equal('client-challenge') + expect(result.code_challenge_method).to.equal('S256') + expect(result.code_verifier).to.be.undefined + }) + + it('should default code_challenge_method to S256 for mobile', function () { + const req = { query: { code_challenge: 'cc' } } + const result = strategy.generatePkce(req, true) + + expect(result.code_challenge_method).to.equal('S256') + }) + + it('should error when mobile has no code_challenge', function () { + const result = strategy.generatePkce({ query: {} }, true) + expect(result.error).to.include('code_challenge required') + }) + + it('should error when mobile uses non-S256 method', function () { + const req = { query: { code_challenge: 'cc', code_challenge_method: 'plain' } } + const result = strategy.generatePkce(req, true) + expect(result.error).to.include('Only S256') + }) + }) + + // ── handleMobileRedirect ───────────────────────────────────────────── + + describe('handleMobileRedirect', function () { + let res + + beforeEach(function () { + res = { + redirect: sinon.stub(), + status: sinon.stub().returnsThis(), + send: sinon.stub() + } + }) + + it('should redirect to mobile app with code on success', function () { + strategy.openIdAuthSession.set('valid-state', { + mobile_redirect_uri: 'audiobookshelf://oauth', + nonce: 'n', + sso_redirect_uri: 'http://localhost/auth/openid/mobile-redirect' + }) + + const req = { query: { state: 'valid-state', code: 'auth-code-123' } } + strategy.handleMobileRedirect(req, res) + + expect(res.redirect.calledOnce).to.be.true + const redirectUrl = res.redirect.firstCall.args[0] + expect(redirectUrl).to.include('audiobookshelf://oauth') + expect(redirectUrl).to.include('code=auth-code-123') + expect(redirectUrl).to.include('state=valid-state') + // Should keep Map entry for the callback + expect(strategy.openIdAuthSession.has('valid-state')).to.be.true + }) + + it('should forward IdP error to mobile app and clean up Map', function () { + strategy.openIdAuthSession.set('err-state', { + mobile_redirect_uri: 'audiobookshelf://oauth', + nonce: 'n', + sso_redirect_uri: 'http://localhost/auth/openid/mobile-redirect' + }) + + const req = { query: { state: 'err-state', error: 'access_denied', error_description: 'User denied' } } + strategy.handleMobileRedirect(req, res) + + expect(res.redirect.calledOnce).to.be.true + const redirectUrl = res.redirect.firstCall.args[0] + expect(redirectUrl).to.include('error=access_denied') + expect(redirectUrl).to.include('error_description=User+denied') + // Should clean up Map on error + expect(strategy.openIdAuthSession.has('err-state')).to.be.false + }) + + it('should return 400 when state is missing', function () { + const req = { query: {} } + strategy.handleMobileRedirect(req, res) + + expect(res.status.calledWith(400)).to.be.true + expect(res.send.calledOnce).to.be.true + }) + + it('should return 400 when state is not in Map', function () { + const req = { query: { state: 'unknown-state', code: 'code-123' } } + strategy.handleMobileRedirect(req, res) + + expect(res.status.calledWith(400)).to.be.true + }) + + it('should return 400 when Map entry has no mobile_redirect_uri', function () { + strategy.openIdAuthSession.set('no-uri-state', { + nonce: 'n', + sso_redirect_uri: 'http://localhost/auth/openid/mobile-redirect' + // missing mobile_redirect_uri + }) + + const req = { query: { state: 'no-uri-state', code: 'code-123' } } + strategy.handleMobileRedirect(req, res) + + expect(res.status.calledWith(400)).to.be.true + }) + }) + + // ── cleanupStaleAuthSessions ───────────────────────────────────────── + + describe('cleanupStaleAuthSessions', function () { + it('should remove sessions older than 10 minutes', function () { + const now = Date.now() + strategy.openIdAuthSession.set('old', { created_at: now - 11 * 60 * 1000 }) + strategy.openIdAuthSession.set('fresh', { created_at: now - 1000 }) + + strategy.cleanupStaleAuthSessions() + + expect(strategy.openIdAuthSession.has('old')).to.be.false + expect(strategy.openIdAuthSession.has('fresh')).to.be.true + }) + + it('should enforce maximum size of 1000', function () { + for (let i = 0; i < 1050; i++) { + strategy.openIdAuthSession.set(`state-${i}`, { created_at: Date.now() }) + } + + strategy.cleanupStaleAuthSessions() + + expect(strategy.openIdAuthSession.size).to.be.at.most(1000) + }) + + it('should evict oldest entries first when over limit', function () { + const now = Date.now() + for (let i = 0; i < 1050; i++) { + strategy.openIdAuthSession.set(`state-${i}`, { created_at: now + i }) + } + + strategy.cleanupStaleAuthSessions() + + // Oldest entries (lowest i) should be evicted + expect(strategy.openIdAuthSession.has('state-0')).to.be.false + // Newest entries should survive + expect(strategy.openIdAuthSession.has('state-1049')).to.be.true + }) + }) +}) diff --git a/test/server/auth/OidcSettingsSchema.test.js b/test/server/auth/OidcSettingsSchema.test.js new file mode 100644 index 000000000..ecb7639dc --- /dev/null +++ b/test/server/auth/OidcSettingsSchema.test.js @@ -0,0 +1,173 @@ +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 not hang on pathological URI input', function () { + this.timeout(1000) + const result = validateSettings({ + ...validSettings, + authOpenIDMobileRedirectURIs: ['a://-/' + '/'.repeat(100) + '!'] + }) + expect(result.valid).to.be.false + expect(result.errors[0]).to.include('Invalid URI') + }) + + it('should accept URI with path segments', function () { + const result = validateSettings({ + ...validSettings, + authOpenIDMobileRedirectURIs: ['https://example.com/path/to/callback'] + }) + expect(result.valid).to.be.true + }) + + 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 + }) +})