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') /** * OpenID Connect authentication strategy */ 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} */ getClient() { if (!this.client) { if (!Database.serverSettings.isOpenIDAuthSettingsValid) { throw new Error('OpenID Connect settings are not valid') } // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 }) const openIdIssuerClient = new OpenIDClient.Issuer({ issuer: global.ServerSettings.authOpenIDIssuerURL, authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, token_endpoint: global.ServerSettings.authOpenIDTokenURL, userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, jwks_uri: global.ServerSettings.authOpenIDJwksURL, end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL }).Client this.client = new openIdIssuerClient({ client_id: global.ServerSettings.authOpenIDClientID, client_secret: global.ServerSettings.authOpenIDClientSecret, id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm }) } return this.client } /** * Get the scope string for the OpenID Connect request * @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 } /** * Initialize the strategy with passport */ 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 this.client = null } /** * Verify callback for OpenID Connect authentication * @param {Object} tokenset * @param {Object} userinfo * @param {Function} done - Passport callback */ async verifyCallback(tokenset, userinfo, done) { try { Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) if (!userinfo.sub) { throw new Error('Invalid userinfo, no sub') } if (!this.validateGroupClaim(userinfo)) { throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) } let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) if (!user?.isActive) { throw new Error('User not active or not found') } 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 user.openid_id_token = tokenset.id_token return done(null, user) } catch (error) { Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`) return done(null, null, 'Unauthorized') } } /** * Validates the presence and content of the group claim in userinfo. * @param {Object} userinfo * @returns {boolean} */ validateGroupClaim(userinfo) { const groupClaimName = Database.serverSettings.authOpenIDGroupClaim if (!groupClaimName) // Allow no group claim when configured like this return true // If configured it must exist in userinfo if (!userinfo[groupClaimName]) { return false } return true } /** * Sets the user group based on group claim in userinfo. * @param {import('../models/User')} user * @param {Object} userinfo */ async setUserGroup(user, userinfo) { const groupClaimName = Database.serverSettings.authOpenIDGroupClaim if (!groupClaimName) // No group claim configured, don't set anything return if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`) const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) const rolesInOrderOfPriority = ['admin', 'user', 'guest'] 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.`) } else { // If root user is logging in via OpenID, we will not change the type return } } if (user.type !== userType) { Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`) user.type = userType await user.save() } } else { throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) } } /** * Updates user permissions based on the advanced permissions claim. * @param {import('../models/User')} user * @param {Object} userinfo */ async updateUserPermissions(user, userinfo) { const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything return 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 (await user.updatePermissionsFromExternalJSON(absPermissions)) { Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) } } /** * Generate PKCE parameters for the authorization request * @param {Request} req * @param {boolean} isMobileFlow * @returns {Object|{error: string}} */ generatePkce(req, isMobileFlow) { if (isMobileFlow) { if (!req.query.code_challenge) { return { error: 'code_challenge required for mobile flow (PKCE)' } } if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') { return { error: 'Only S256 code_challenge_method method supported' } } return { code_challenge: req.query.code_challenge, code_challenge_method: req.query.code_challenge_method || 'S256' } } else { const code_verifier = OpenIDClient.generators.codeVerifier() const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) return { code_challenge, code_challenge_method: 'S256', code_verifier } } } /** * Check if a redirect URI is valid * @param {string} uri * @returns {boolean} */ 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] === '*') } /** * 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}} */ getAuthorizationUrl(req) { 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') { Logger.debug(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`) return { status: 400, error: 'Invalid response_type, only code supported' } } // Generate a state on web flow or if no state supplied const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state // Redirect URL for the SSO provider 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 { status: 400, 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 }) redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() } else { redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() if (req.query.state) { Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`) return { status: 400, error: 'Invalid state, not allowed on web flow' } } } // 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) if (pkceData.error) { return { status: 400, error: pkceData.error } } req.session[sessionKey] = { ...req.session[sessionKey], state: state, max_age: strategy._params.max_age, response_type: 'code', code_verifier: pkceData.code_verifier, // not null if web flow mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback } const authorizationUrl = client.authorizationUrl({ ...strategy._params, redirect_uri: redirectUri, state: state, response_type: 'code', scope: this.getScope(), code_challenge: pkceData.code_challenge, code_challenge_method: pkceData.code_challenge_method }) return { authorizationUrl, isMobileFlow } } catch (error) { Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`) return { status: 500, error: error.message || 'Unknown error' } } } /** * Get the end session URL for logout * @param {Request} req * @param {string} idToken * @param {string} authMethod * @returns {string|null} */ getEndSessionUrl(req, idToken, authMethod) { const client = this.getClient() if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) { let postLogoutRedirectUri = null 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) return client.endSessionUrl({ id_token_hint: idToken, post_logout_redirect_uri: postLogoutRedirectUri }) } return null } /** * @typedef {Object} OpenIdIssuerConfig * @property {string} issuer * @property {string} authorization_endpoint * @property {string} token_endpoint * @property {string} userinfo_endpoint * @property {string} end_session_endpoint * @property {string} jwks_uri * @property {string} id_token_signing_alg_values_supported * * Get OpenID Connect configuration from an issuer URL * @param {string} issuerUrl * @returns {Promise} */ async getIssuerConfig(issuerUrl) { // Strip trailing slash if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) // Append config pathname and validate URL let configUrl = null try { configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`) if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) { throw new Error('Invalid pathname') } } catch (error) { Logger.error(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) return { status: 400, error: "Invalid request. Query param 'issuer' is invalid" } } try { const { data } = await axios.get(configUrl.toString()) return { issuer: data.issuer, authorization_endpoint: data.authorization_endpoint, token_endpoint: data.token_endpoint, userinfo_endpoint: data.userinfo_endpoint, end_session_endpoint: data.end_session_endpoint, jwks_uri: data.jwks_uri, id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported } } catch (error) { Logger.error(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error) return { status: 400, error: 'Failed to get openid configuration' } } } /** * Handle mobile redirect for OAuth2 callback * @param {Request} req * @param {Response} res */ handleMobileRedirect(req, res) { try { // Extract the state parameter from the request const { state, code } = req.query // Check if the state provided is in our list if (!state || !this.openIdAuthSession.has(state)) { Logger.error('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch') return res.status(400).send('State parameter mismatch') } let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri if (!mobile_redirect_uri) { Logger.error('[OidcAuth] No redirect URI') return res.status(400).send('No redirect URI') } this.openIdAuthSession.delete(state) const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` // Redirect to the overwrite URI saved in the map res.redirect(redirectUri) } catch (error) { Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) res.status(500).send('Internal Server Error') } } } module.exports = OidcAuthStrategy