-
-
-
-
-
-
- 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
+ })
+})