mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 05:29:41 +00:00
Some IdPs (e.g. Authentik) don't echo the nonce in the id_token for the authorization code flow, causing "nonce mismatch, got: undefined" errors when the mobile app attempts SSO login. The mobile flow already uses PKCE which provides equivalent replay protection, so nonce is not needed. Web flow continues to use nonce for defense-in-depth.
667 lines
24 KiB
JavaScript
667 lines
24 KiB
JavaScript
const { Request, Response } = require('express')
|
|
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 (no Passport wrapper)
|
|
*/
|
|
class OidcAuthStrategy {
|
|
constructor() {
|
|
this.client = null
|
|
// Map of openId sessions indexed by oauth2 state-variable
|
|
this.openIdAuthSession = new Map()
|
|
}
|
|
|
|
/**
|
|
* Get the OpenID Connect client
|
|
* @returns {OpenIDClient.Client}
|
|
*/
|
|
getClient() {
|
|
if (!this.client) {
|
|
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
|
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
|
|
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() {
|
|
return global.ServerSettings.authOpenIDScopes || 'openid profile email'
|
|
}
|
|
|
|
/**
|
|
* Reload the OIDC strategy after settings change (replaces init/unuse)
|
|
*/
|
|
reload() {
|
|
this.client = null
|
|
this.openIdAuthSession.clear()
|
|
Logger.info('[OidcAuth] Settings reloaded')
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @returns {Promise<import('../models/User')>}
|
|
* @throws {AuthError}
|
|
*/
|
|
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 AuthError('Invalid userinfo, no sub', 401)
|
|
}
|
|
|
|
if (!this.validateGroupClaim(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) {
|
|
Logger.warn(`[OidcAuth] User lookup failed: ${user.error}`)
|
|
throw new AuthError(user.error, 401)
|
|
}
|
|
|
|
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`)
|
|
throw new AuthError('User not found and auto-register is disabled', 401)
|
|
}
|
|
}
|
|
|
|
if (!user.isActive) {
|
|
throw new AuthError('User not active or not found', 401)
|
|
}
|
|
|
|
await this.setUserGroup(user, userinfo)
|
|
await this.updateUserPermissions(user, userinfo)
|
|
|
|
// Save the id_token for later (used for logout via DB session)
|
|
user.openid_id_token = tokenset.id_token
|
|
|
|
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()
|
|
}
|
|
if (error instanceof AuthError) {
|
|
throw error
|
|
}
|
|
throw new AuthError(error.message || 'Unauthorized', 401)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
* Supports explicit group mapping via authOpenIDGroupMap or legacy direct name match.
|
|
*
|
|
* @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 AuthError(`Group claim ${groupClaimName} not found in userinfo`, 401)
|
|
|
|
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))
|
|
}
|
|
|
|
if (userType) {
|
|
if (user.type === 'root') {
|
|
// Check OpenID Group
|
|
if (userType !== 'admin') {
|
|
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
|
|
}
|
|
}
|
|
|
|
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 {
|
|
Logger.warn(`[OidcAuth] No valid group found in userinfo groups: ${JSON.stringify(userinfo[groupClaimName])}`)
|
|
throw new AuthError('No valid group found in userinfo', 401)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 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)}"`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @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, isMobileFlow, validatedCallback) {
|
|
const client = this.getClient()
|
|
|
|
try {
|
|
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
|
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
|
|
|
// 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 (!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'
|
|
}
|
|
}
|
|
// 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 {
|
|
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'
|
|
}
|
|
}
|
|
}
|
|
|
|
Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)
|
|
|
|
const pkceData = this.generatePkce(req, isMobileFlow)
|
|
if (pkceData.error) {
|
|
return {
|
|
status: 400,
|
|
error: pkceData.error
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
nonce: nonce,
|
|
response_type: 'code',
|
|
code_verifier: pkceData.code_verifier, // not null if web flow
|
|
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({
|
|
redirect_uri: redirectUri,
|
|
state: state,
|
|
nonce: nonce,
|
|
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')
|
|
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
|
}
|
|
// else for openid-mobile we keep postLogoutRedirectUri on null
|
|
// 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,
|
|
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, error, error_description } = 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')
|
|
}
|
|
|
|
const sessionEntry = this.openIdAuthSession.get(state)
|
|
|
|
if (!sessionEntry.mobile_redirect_uri) {
|
|
Logger.error('[OidcAuth] No redirect URI')
|
|
return res.status(400).send('No redirect URI')
|
|
}
|
|
|
|
// Use URL object to safely append parameters (avoids fragment injection)
|
|
const redirectUrl = new URL(sessionEntry.mobile_redirect_uri)
|
|
redirectUrl.searchParams.set('state', state)
|
|
|
|
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')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
// NPM appends both http and https in x-forwarded-proto sometimes, so we need to check for both
|
|
const xfp = (req.get('x-forwarded-proto') || '').toLowerCase()
|
|
const currentProtocol =
|
|
req.secure ||
|
|
xfp
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.includes('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
|