mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			553 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			553 lines
		
	
	
		
			20 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| 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) {
 | |
|     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')
 | |
|       }
 | |
| 
 | |
|       if (!this.validateGroupClaim(userinfo)) {
 | |
|         throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
 | |
|       }
 | |
| 
 | |
|       user = await Database.userModel.findUserFromOpenIdUserInfo(userinfo)
 | |
| 
 | |
|       if (user?.error) {
 | |
|         throw new Error('Invalid userinfo or already linked')
 | |
|       }
 | |
| 
 | |
|       if (!user) {
 | |
|         // If no existing user was matched, auto-register if configured
 | |
|         if (global.ServerSettings.authOpenIDAutoRegister) {
 | |
|           Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
 | |
|           user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo)
 | |
|           isNewUser = true
 | |
|         } else {
 | |
|           Logger.warn(`[User] openid: User not found and auto-register is disabled`)
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       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}`)
 | |
|       // Remove new user if an error occurs
 | |
|       if (isNewUser && user) {
 | |
|         await user.destroy()
 | |
|       }
 | |
|       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<OpenIdIssuerConfig|{status: number, error: string}>}
 | |
|    */
 | |
|   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')
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Validates if a callback URL is safe for redirect (same-origin only)
 | |
|    * @param {string} callbackUrl - The callback URL to validate
 | |
|    * @param {Request} req - Express request object to get current host
 | |
|    * @returns {boolean} - True if the URL is safe (same-origin), false otherwise
 | |
|    */
 | |
|   isValidWebCallbackUrl(callbackUrl, req) {
 | |
|     if (!callbackUrl) return false
 | |
| 
 | |
|     try {
 | |
|       // Handle relative URLs - these are always safe if they start with router base path
 | |
|       if (callbackUrl.startsWith('/')) {
 | |
|         // Only allow relative paths that start with the router base path
 | |
|         if (callbackUrl.startsWith(global.RouterBasePath + '/')) {
 | |
|           return true
 | |
|         }
 | |
|         Logger.warn(`[OidcAuth] Rejected callback URL outside router base path: ${callbackUrl}`)
 | |
|         return false
 | |
|       }
 | |
| 
 | |
|       // For absolute URLs, ensure they point to the same origin
 | |
|       const callbackUrlObj = new URL(callbackUrl)
 | |
|       const currentProtocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
 | |
|       const currentHost = req.get('host')
 | |
| 
 | |
|       // Check if protocol and host match exactly
 | |
|       if (callbackUrlObj.protocol === currentProtocol + ':' && callbackUrlObj.host === currentHost) {
 | |
|         // Additional check: ensure path starts with router base path
 | |
|         if (callbackUrlObj.pathname.startsWith(global.RouterBasePath + '/')) {
 | |
|           return true
 | |
|         }
 | |
|         Logger.warn(`[OidcAuth] Rejected same-origin callback URL outside router base path: ${callbackUrl}`)
 | |
|         return false
 | |
|       }
 | |
| 
 | |
|       Logger.warn(`[OidcAuth] Rejected callback URL to different origin: ${callbackUrl} (expected ${currentProtocol}://${currentHost})`)
 | |
|       return false
 | |
|     } catch (error) {
 | |
|       Logger.error(`[OidcAuth] Invalid callback URL format: ${callbackUrl}`, error)
 | |
|       return false
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| module.exports = OidcAuthStrategy
 |