mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 01:41:35 +00:00
A user promoted via the group claim had its type changed but kept the permissions of its previous type (e.g. a new user mapped to admin could not upload). Update permissions from the new type's defaults when the type changes, and cover it in the setUserGroup tests.
564 lines
20 KiB
JavaScript
564 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 adminGroups = (Database.serverSettings.authOpenIDAdminGroups || '').split(',').map((group) => group.trim().toLowerCase()).filter(Boolean)
|
|
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()).map((group) => (adminGroups.includes(group) ? 'admin' : group))
|
|
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
|
|
|
const defaultRole = (Database.serverSettings.authOpenIDGroupDefaultRole || '').toLowerCase()
|
|
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) || (rolesInOrderOfPriority.includes(defaultRole) ? defaultRole : undefined)
|
|
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
|
|
user.permissions = Database.userModel.getDefaultPermissionsForUserType(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)
|
|
// 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
|