mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 13:39:41 +00:00
Implement OIDC Back-Channel Logout 1.0 (RFC). When enabled, the IdP can POST a signed logout_token JWT to invalidate user sessions server-side. - Add BackchannelLogoutHandler: JWT verification via jose, jti replay protection with bounded cache, session destruction by sub or sid - Add oidcSessionId column to sessions table with index for fast lookups - Add backchannel logout route (POST /auth/openid/backchannel-logout) - Notify connected clients via socket to redirect to login page - Add authOpenIDBackchannelLogoutEnabled toggle in schema-driven settings UI - Migration v2.34.0 adds oidcSessionId column and index - Polish settings UI: auto-populate loading state, subfolder dropdown options, KeyValueEditor fixes, localized descriptions via descriptionKey, duplicate key detection, success/error toasts - Localize backchannel logout toast (ToastSessionEndedByProvider) - OidcAuthStrategy tests now use real class via require-cache stubbing
484 lines
17 KiB
JavaScript
484 lines
17 KiB
JavaScript
const { Request, Response, NextFunction } = require('express')
|
|
const passport = require('passport')
|
|
const JwtStrategy = require('passport-jwt').Strategy
|
|
const ExtractJwt = require('passport-jwt').ExtractJwt
|
|
|
|
const Database = require('./Database')
|
|
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')
|
|
|
|
/**
|
|
* @class Class for handling all the authentication related functionality.
|
|
*/
|
|
class Auth {
|
|
constructor() {
|
|
const escapedRouterBasePath = escapeRegExp(global.RouterBasePath)
|
|
this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)]
|
|
|
|
/** @type {import('express-rate-limit').RateLimitRequestHandler} */
|
|
this.authRateLimiter = RateLimiterFactory.getAuthRateLimiter()
|
|
|
|
this.tokenManager = new TokenManager()
|
|
this.localAuthStrategy = new LocalAuthStrategy()
|
|
this.oidcAuthStrategy = new OidcAuthStrategy()
|
|
this.backchannelLogoutHandler = new BackchannelLogoutHandler()
|
|
}
|
|
|
|
/**
|
|
* Checks if the request should not be authenticated.
|
|
* @param {Request} req
|
|
* @returns {boolean}
|
|
*/
|
|
authNotNeeded(req) {
|
|
return req.method === 'GET' && this.ignorePatterns.some((pattern) => pattern.test(req.path))
|
|
}
|
|
|
|
/**
|
|
* Middleware to register passport in express-session
|
|
*
|
|
* @param {function} middleware
|
|
*/
|
|
ifAuthNeeded(middleware) {
|
|
return (req, res, next) => {
|
|
if (this.authNotNeeded(req)) {
|
|
return next()
|
|
}
|
|
middleware(req, res, next)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* middleware to use in express to only allow authenticated users.
|
|
*
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
* @param {NextFunction} next
|
|
*/
|
|
isAuthenticated(req, res, next) {
|
|
return passport.authenticate('jwt', { session: false })(req, res, next)
|
|
}
|
|
|
|
/**
|
|
* Function to generate a jwt token for a given user
|
|
* TODO: Old method with no expiration
|
|
* @deprecated
|
|
*
|
|
* @param {{ id:string, username:string }} user
|
|
* @returns {string}
|
|
*/
|
|
generateAccessToken(user) {
|
|
return this.tokenManager.generateAccessToken(user)
|
|
}
|
|
|
|
/**
|
|
* Invalidate all JWT sessions for a given user
|
|
* If user is current user and refresh token is valid, rotate tokens for the current session
|
|
*
|
|
* @param {import('./models/User')} user
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
|
|
*/
|
|
async invalidateJwtSessionsForUser(user, req, res) {
|
|
return this.tokenManager.invalidateJwtSessionsForUser(user, req, res)
|
|
}
|
|
|
|
/**
|
|
* Return the login info payload for a user
|
|
*
|
|
* @param {import('./models/User')} user
|
|
* @returns {Promise<Object>} jsonPayload
|
|
*/
|
|
async getUserLoginResponsePayload(user) {
|
|
const libraryIds = await Database.libraryModel.getAllLibraryIds()
|
|
return {
|
|
user: user.toOldJSONForBrowser(),
|
|
userDefaultLibraryId: user.getDefaultLibraryId(libraryIds),
|
|
serverSettings: Database.serverSettings.toJSONForBrowser(),
|
|
ereaderDevices: Database.emailSettings.getEReaderDevices(user),
|
|
Source: global.Source
|
|
}
|
|
}
|
|
|
|
// #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)
|
|
if (global.ServerSettings.authActiveAuthMethods.includes('local')) {
|
|
this.localAuthStrategy.init()
|
|
}
|
|
|
|
// OIDC no longer needs passport initialization - it handles tokens directly
|
|
|
|
// Load the JwtStrategy (always) -> for bearer token auth
|
|
passport.use(
|
|
new JwtStrategy(
|
|
{
|
|
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
|
|
secretOrKey: TokenManager.TokenSecret,
|
|
// Handle expiration manaully in order to disable api keys that are expired
|
|
ignoreExpiration: true
|
|
},
|
|
this.tokenManager.jwtAuthCheck.bind(this)
|
|
)
|
|
)
|
|
|
|
// define how to seralize a user (to be put into the session)
|
|
passport.serializeUser(function (user, cb) {
|
|
process.nextTick(function () {
|
|
// only store id to session
|
|
return cb(
|
|
null,
|
|
JSON.stringify({
|
|
id: user.id
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
// define how to deseralize a user (use the ID to get it from the database)
|
|
passport.deserializeUser(
|
|
function (user, cb) {
|
|
process.nextTick(
|
|
async function () {
|
|
const parsedUserInfo = JSON.parse(user)
|
|
// load the user by ID that is stored in the session
|
|
const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)
|
|
return cb(null, dbUser)
|
|
}.bind(this)
|
|
)
|
|
}.bind(this)
|
|
)
|
|
}
|
|
// #endregion
|
|
|
|
/**
|
|
* Unuse strategy
|
|
*
|
|
* @param {string} name
|
|
*/
|
|
unuseAuthStrategy(name) {
|
|
if (name === 'openid') {
|
|
this.oidcAuthStrategy.reload()
|
|
this.backchannelLogoutHandler.reset()
|
|
} else if (name === 'local') {
|
|
this.localAuthStrategy.unuse()
|
|
} else {
|
|
Logger.error('[Auth] Invalid auth strategy ' + name)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Use strategy
|
|
*
|
|
* @param {string} name
|
|
*/
|
|
useAuthStrategy(name) {
|
|
if (name === 'openid') {
|
|
this.oidcAuthStrategy.reload()
|
|
this.backchannelLogoutHandler.reset()
|
|
} else if (name === 'local') {
|
|
this.localAuthStrategy.init()
|
|
} else {
|
|
Logger.error('[Auth] Invalid auth strategy ' + name)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns if the given auth method is API based.
|
|
*
|
|
* @param {string} authMethod
|
|
* @returns {boolean}
|
|
*/
|
|
isAuthMethodAPIBased(authMethod) {
|
|
return ['api', 'openid-mobile'].includes(authMethod)
|
|
}
|
|
|
|
/**
|
|
* After login success from local auth
|
|
* req.user is set by passport.authenticate
|
|
*
|
|
* attaches the access token to the user in the response
|
|
* if returnTokens is true, also attaches the refresh token to the user in the response
|
|
*
|
|
* if returnTokens is false, sets the refresh token cookie
|
|
*
|
|
* @param {Request} req
|
|
* @param {Response} res
|
|
* @param {boolean} returnTokens
|
|
*/
|
|
async handleLoginSuccess(req, res, returnTokens = false) {
|
|
// Create tokens and session
|
|
const { accessToken, refreshToken } = await this.tokenManager.createTokensAndSession(req.user, req)
|
|
|
|
const userResponse = await this.getUserLoginResponsePayload(req.user)
|
|
|
|
userResponse.user.refreshToken = returnTokens ? refreshToken : null
|
|
userResponse.user.accessToken = accessToken
|
|
|
|
Logger.debug(`[Auth] handleLoginSuccess: returnTokens: ${returnTokens}, isRefreshTokenInResponse: ${!!userResponse.user.refreshToken}`)
|
|
|
|
if (!returnTokens) {
|
|
this.tokenManager.setRefreshTokenCookie(req, res, refreshToken)
|
|
}
|
|
|
|
return userResponse
|
|
}
|
|
|
|
// #region Auth routes
|
|
/**
|
|
* Creates all (express) routes required for authentication.
|
|
*
|
|
* @param {import('express').Router} router
|
|
*/
|
|
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'
|
|
|
|
const userResponse = await this.handleLoginSuccess(req, res, returnTokens)
|
|
res.json(userResponse)
|
|
})
|
|
|
|
// Refresh token route
|
|
router.post('/auth/refresh', this.authRateLimiter, async (req, res) => {
|
|
let refreshToken = req.cookies.refresh_token
|
|
|
|
// If x-refresh-token header is present, use it instead of the cookie
|
|
// and return the refresh token in the response
|
|
let shouldReturnRefreshToken = false
|
|
if (req.headers['x-refresh-token']) {
|
|
refreshToken = req.headers['x-refresh-token']
|
|
shouldReturnRefreshToken = true
|
|
}
|
|
|
|
if (!refreshToken) {
|
|
Logger.error(`[Auth] Failed to refresh token. No refresh token provided`)
|
|
return res.status(401).json({ error: 'No refresh token provided' })
|
|
}
|
|
|
|
Logger.debug(`[Auth] refreshing token. shouldReturnRefreshToken: ${shouldReturnRefreshToken}`)
|
|
|
|
const refreshResponse = await this.tokenManager.handleRefreshToken(refreshToken, req, res)
|
|
if (refreshResponse.error) {
|
|
return res.status(401).json({ error: refreshResponse.error })
|
|
}
|
|
|
|
const userResponse = await this.getUserLoginResponsePayload(refreshResponse.user)
|
|
|
|
userResponse.user.accessToken = refreshResponse.accessToken
|
|
userResponse.user.refreshToken = shouldReturnRefreshToken ? refreshResponse.refreshToken : null
|
|
res.json(userResponse)
|
|
})
|
|
|
|
// openid strategy login route (this redirects to the configured openid login provider)
|
|
router.get('/auth/openid', this.authRateLimiter, (req, res) => {
|
|
// 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 (!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' })
|
|
}
|
|
}
|
|
|
|
const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req, isMobileFlow, callback)
|
|
|
|
if (authorizationUrlResponse.error) {
|
|
return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error)
|
|
}
|
|
|
|
res.redirect(authorizationUrlResponse.authorizationUrl)
|
|
})
|
|
|
|
// This will be the oauth2 callback route for mobile clients
|
|
// 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 - 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
|
|
|
|
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 (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 {
|
|
res.status(400).send('No callback URL')
|
|
}
|
|
}
|
|
} 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`)
|
|
}
|
|
} 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"
|
|
*
|
|
* @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/
|
|
*/
|
|
router.get('/auth/openid/config', this.authRateLimiter, this.isAuthenticated, async (req, res) => {
|
|
if (!req.user.isAdminOrUp) {
|
|
Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`)
|
|
return res.sendStatus(403)
|
|
}
|
|
|
|
if (!req.query.issuer || typeof req.query.issuer !== 'string') {
|
|
return res.status(400).send("Invalid request. Query param 'issuer' is required")
|
|
}
|
|
|
|
const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer)
|
|
if (openIdIssuerConfig.error) {
|
|
return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error)
|
|
}
|
|
|
|
res.json(openIdIssuerConfig)
|
|
})
|
|
|
|
// Logout route
|
|
router.post('/logout', async (req, res) => {
|
|
// Refresh token can alternatively be sent in the header
|
|
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
|
|
|
|
// Clear refresh token cookie
|
|
res.clearCookie('refresh_token', {
|
|
path: '/'
|
|
})
|
|
|
|
// 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`)
|
|
}
|
|
|
|
req.logout((err) => {
|
|
if (err) {
|
|
res.sendStatus(500)
|
|
} else {
|
|
const authMethod = req.cookies.auth_method
|
|
|
|
res.clearCookie('auth_method')
|
|
|
|
let logoutUrl = null
|
|
|
|
if (authMethod === 'openid' || authMethod === 'openid-mobile') {
|
|
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
|
|
// (or redirect_url: null if we don't have one)
|
|
res.send({ redirect_url: logoutUrl })
|
|
}
|
|
})
|
|
})
|
|
|
|
// 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') || !global.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
|
|
}
|
|
|
|
module.exports = Auth
|