mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-05 07:29:42 +00:00
Add OIDC Back-Channel Logout support
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
This commit is contained in:
parent
33bee70a12
commit
073eff74ef
16 changed files with 886 additions and 104 deletions
148
server/auth/BackchannelLogoutHandler.js
Normal file
148
server/auth/BackchannelLogoutHandler.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
const { createRemoteJWKSet, jwtVerify } = require('jose')
|
||||
const Logger = require('../Logger')
|
||||
const Database = require('../Database')
|
||||
const SocketAuthority = require('../SocketAuthority')
|
||||
|
||||
class BackchannelLogoutHandler {
|
||||
/** Maximum number of jti entries to keep (bounded by rate limiter: 40 req/10min) */
|
||||
static MAX_JTI_CACHE_SIZE = 500
|
||||
|
||||
constructor() {
|
||||
/** @type {import('jose').GetKeyFunction|null} */
|
||||
this._jwks = null
|
||||
/** @type {Map<string, number>} jti -> expiry timestamp for replay protection */
|
||||
this._usedJtis = new Map()
|
||||
}
|
||||
|
||||
/** Reset cached JWKS and jti cache (called when OIDC settings change) */
|
||||
reset() {
|
||||
this._jwks = null
|
||||
this._usedJtis.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a jti has already been used (replay protection)
|
||||
* @param {string} jti
|
||||
* @returns {boolean} true if the jti is a replay
|
||||
*/
|
||||
_isReplayedJti(jti) {
|
||||
const now = Date.now()
|
||||
|
||||
// Prune expired entries periodically (every check, cheap since Map is small)
|
||||
for (const [key, expiry] of this._usedJtis) {
|
||||
if (expiry < now) this._usedJtis.delete(key)
|
||||
}
|
||||
|
||||
if (this._usedJtis.has(jti)) return true
|
||||
|
||||
// Enforce upper bound to prevent unbounded growth
|
||||
if (this._usedJtis.size >= BackchannelLogoutHandler.MAX_JTI_CACHE_SIZE) {
|
||||
// Drop the oldest entry (first inserted in Map iteration order)
|
||||
const oldestKey = this._usedJtis.keys().next().value
|
||||
this._usedJtis.delete(oldestKey)
|
||||
}
|
||||
|
||||
// Store with 5-minute TTL (matches maxTokenAge)
|
||||
this._usedJtis.set(jti, now + 5 * 60 * 1000)
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the JWKS key function for JWT verification
|
||||
* @returns {import('jose').GetKeyFunction}
|
||||
*/
|
||||
_getJwks() {
|
||||
if (!this._jwks) {
|
||||
const jwksUrl = global.ServerSettings.authOpenIDJwksURL
|
||||
if (!jwksUrl) throw new Error('JWKS URL not configured')
|
||||
this._jwks = createRemoteJWKSet(new URL(jwksUrl))
|
||||
}
|
||||
return this._jwks
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and process a backchannel logout token
|
||||
* @see https://openid.net/specs/openid-connect-backchannel-1_0.html#Validation
|
||||
* @param {string} logoutToken - the raw JWT logout_token from the IdP
|
||||
* @returns {Promise<{success: boolean, error?: string}>}
|
||||
*/
|
||||
async processLogoutToken(logoutToken) {
|
||||
try {
|
||||
// Verify JWT signature, issuer, audience, and max age
|
||||
const { payload } = await jwtVerify(logoutToken, this._getJwks(), {
|
||||
issuer: global.ServerSettings.authOpenIDIssuerURL,
|
||||
audience: global.ServerSettings.authOpenIDClientID,
|
||||
maxTokenAge: '5m'
|
||||
})
|
||||
|
||||
// Check that the events claim contains the backchannel logout event
|
||||
const events = payload.events
|
||||
if (!events || typeof events !== 'object' || !('http://schemas.openid.net/event/backchannel-logout' in events)) {
|
||||
Logger.warn('[BackchannelLogout] Missing or invalid events claim')
|
||||
return { success: false, error: 'invalid_request' }
|
||||
}
|
||||
|
||||
// Spec: logout token MUST contain a jti claim
|
||||
if (!payload.jti) {
|
||||
Logger.warn('[BackchannelLogout] Missing jti claim')
|
||||
return { success: false, error: 'invalid_request' }
|
||||
}
|
||||
|
||||
// Replay protection: reject tokens with previously seen jti
|
||||
if (this._isReplayedJti(payload.jti)) {
|
||||
Logger.warn(`[BackchannelLogout] Replayed jti=${payload.jti}`)
|
||||
return { success: false, error: 'invalid_request' }
|
||||
}
|
||||
|
||||
// Spec: logout token MUST NOT contain a nonce claim
|
||||
if (payload.nonce !== undefined) {
|
||||
Logger.warn('[BackchannelLogout] Token contains nonce claim (not allowed)')
|
||||
return { success: false, error: 'invalid_request' }
|
||||
}
|
||||
|
||||
const sub = payload.sub
|
||||
const sid = payload.sid
|
||||
|
||||
// Spec: token MUST contain sub, sid, or both
|
||||
if (!sub && !sid) {
|
||||
Logger.warn('[BackchannelLogout] Token contains neither sub nor sid')
|
||||
return { success: false, error: 'invalid_request' }
|
||||
}
|
||||
|
||||
// Destroy sessions and notify clients
|
||||
if (sid) {
|
||||
// Session-level logout: destroy sessions matching the OIDC session ID
|
||||
const destroyedCount = await Database.sessionModel.destroy({ where: { oidcSessionId: sid } })
|
||||
if (destroyedCount === 0) {
|
||||
Logger.warn(`[BackchannelLogout] No sessions found for sid=${sid} (session may predate oidcSessionId migration)`)
|
||||
} else {
|
||||
Logger.info(`[BackchannelLogout] Destroyed ${destroyedCount} session(s) for sid=${sid}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (sub) {
|
||||
const user = await Database.userModel.getUserByOpenIDSub(sub)
|
||||
if (user) {
|
||||
if (!sid) {
|
||||
// User-level logout (no sid): destroy all sessions for this user
|
||||
const destroyedCount = await Database.sessionModel.destroy({ where: { userId: user.id } })
|
||||
Logger.info(`[BackchannelLogout] Destroyed ${destroyedCount} session(s) for user=${user.username} (sub=${sub})`)
|
||||
}
|
||||
|
||||
// Notify connected clients to redirect to login
|
||||
SocketAuthority.clientEmitter(user.id, 'backchannel_logout', {})
|
||||
} else {
|
||||
// Per spec, unknown sub is not an error — the user may have been deleted
|
||||
Logger.warn(`[BackchannelLogout] No user found for sub=${sub}`)
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
Logger.error(`[BackchannelLogout] Token validation failed: ${error.message}`)
|
||||
return { success: false, error: 'invalid_request' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BackchannelLogoutHandler
|
||||
|
|
@ -64,16 +64,26 @@ class OidcAuthStrategy {
|
|||
}
|
||||
|
||||
/**
|
||||
* Clean up stale mobile auth sessions older than 10 minutes
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -81,24 +91,42 @@ class OidcAuthStrategy {
|
|||
* Replaces the passport authenticate + verifyCallback flow.
|
||||
*
|
||||
* @param {Request} req
|
||||
* @returns {Promise<import('../models/User')>} authenticated user
|
||||
* @returns {Promise<{user: import('../models/User'), isMobileCallback: boolean}>} authenticated user and mobile flag
|
||||
* @throws {AuthError}
|
||||
*/
|
||||
async handleCallback(req) {
|
||||
const sessionData = req.session.oidc
|
||||
let sessionData = req.session.oidc
|
||||
let isMobileCallback = false
|
||||
|
||||
if (!sessionData) {
|
||||
throw new AuthError('No OIDC session found', 400)
|
||||
// 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,
|
||||
nonce: mobileSession.nonce,
|
||||
sso_redirect_uri: mobileSession.sso_redirect_uri
|
||||
}
|
||||
isMobileCallback = true
|
||||
} else {
|
||||
throw new AuthError('No OIDC session found', 400)
|
||||
}
|
||||
}
|
||||
|
||||
const client = this.getClient()
|
||||
|
||||
// If the client sends a code_verifier in query, use it (mobile flow)
|
||||
// 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'
|
||||
})
|
||||
|
|
@ -109,7 +137,11 @@ class OidcAuthStrategy {
|
|||
// Verify and find/create user
|
||||
const user = await this.verifyUser(tokenset, userinfo)
|
||||
|
||||
return user
|
||||
// Extract sid from id_token for backchannel logout support
|
||||
const idTokenClaims = tokenset.claims()
|
||||
user.openid_session_id = idTokenClaims?.sid ?? null
|
||||
|
||||
return { user, isMobileCallback }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -356,10 +388,9 @@ class OidcAuthStrategy {
|
|||
error: 'Invalid redirect_uri'
|
||||
}
|
||||
}
|
||||
// We cannot save the supplied redirect_uri in the session, because the mobile client uses browser instead of the API
|
||||
// for the request to mobile-redirect and as such the session is not shared
|
||||
// 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()
|
||||
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri, created_at: Date.now() })
|
||||
|
||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||
} else {
|
||||
|
|
@ -384,9 +415,25 @@ class OidcAuthStrategy {
|
|||
}
|
||||
}
|
||||
|
||||
// Store OIDC session data using fixed key 'oidc'
|
||||
// Generate nonce to bind id_token to this session (OIDC Core 3.1.2.1)
|
||||
const nonce = 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,
|
||||
nonce: nonce,
|
||||
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,
|
||||
|
|
@ -397,6 +444,7 @@ class OidcAuthStrategy {
|
|||
const authorizationUrl = client.authorizationUrl({
|
||||
redirect_uri: redirectUri,
|
||||
state: state,
|
||||
nonce: nonce,
|
||||
response_type: 'code',
|
||||
scope: this.getScope(),
|
||||
code_challenge: pkceData.code_challenge,
|
||||
|
|
@ -508,7 +556,7 @@ class OidcAuthStrategy {
|
|||
handleMobileRedirect(req, res) {
|
||||
try {
|
||||
// Extract the state parameter from the request
|
||||
const { state, code } = req.query
|
||||
const { state, code, error, error_description } = req.query
|
||||
|
||||
// Check if the state provided is in our list
|
||||
if (!state || !this.openIdAuthSession.has(state)) {
|
||||
|
|
@ -516,18 +564,29 @@ class OidcAuthStrategy {
|
|||
return res.status(400).send('State parameter mismatch')
|
||||
}
|
||||
|
||||
let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri
|
||||
const sessionEntry = this.openIdAuthSession.get(state)
|
||||
|
||||
if (!mobile_redirect_uri) {
|
||||
if (!sessionEntry.mobile_redirect_uri) {
|
||||
Logger.error('[OidcAuth] No redirect URI')
|
||||
return res.status(400).send('No redirect URI')
|
||||
}
|
||||
|
||||
this.openIdAuthSession.delete(state)
|
||||
// Use URL object to safely append parameters (avoids fragment injection)
|
||||
const redirectUrl = new URL(sessionEntry.mobile_redirect_uri)
|
||||
redirectUrl.searchParams.set('state', state)
|
||||
|
||||
const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||
// Redirect to the overwrite URI saved in the map
|
||||
res.redirect(redirectUri)
|
||||
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')
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ const groups = [
|
|||
{ id: 'endpoints', label: 'Provider Endpoints', order: 1 },
|
||||
{ id: 'credentials', label: 'Client Credentials', order: 2 },
|
||||
{ id: 'behavior', label: 'Login Behavior', order: 3 },
|
||||
{ id: 'claims', label: 'Claims & Group Mapping', order: 4 },
|
||||
{ id: 'claims', label: 'Claims & Group Mapping', order: 4, descriptionKey: 'LabelOpenIDClaims' },
|
||||
{ id: 'advanced', label: 'Advanced', order: 5 }
|
||||
]
|
||||
|
||||
|
|
@ -173,7 +173,7 @@ const schema = [
|
|||
group: 'claims',
|
||||
order: 2,
|
||||
validate: 'claimName',
|
||||
description: 'Name of the claim containing group membership'
|
||||
descriptionKey: 'LabelOpenIDGroupClaimDescription'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDGroupMap',
|
||||
|
|
@ -192,7 +192,7 @@ const schema = [
|
|||
group: 'claims',
|
||||
order: 4,
|
||||
validate: 'claimName',
|
||||
description: 'Claim containing per-user permissions JSON'
|
||||
descriptionKey: 'LabelOpenIDAdvancedPermsClaimDescription'
|
||||
},
|
||||
|
||||
// Advanced group
|
||||
|
|
@ -216,6 +216,14 @@ const schema = [
|
|||
{ value: '', label: 'None' }
|
||||
],
|
||||
description: 'Subfolder prefix for redirect URLs (e.g. /audiobookshelf)'
|
||||
},
|
||||
{
|
||||
key: 'authOpenIDBackchannelLogoutEnabled',
|
||||
type: 'boolean',
|
||||
label: 'Back-Channel Logout',
|
||||
group: 'advanced',
|
||||
order: 3,
|
||||
description: 'Enable OIDC Back-Channel Logout. Configure your IdP with the logout URL: {baseURL}/auth/openid/backchannel-logout'
|
||||
}
|
||||
]
|
||||
|
||||
|
|
@ -230,7 +238,7 @@ function getSchema() {
|
|||
if (field.key === 'authOpenIDAdvancedPermsClaim') {
|
||||
return {
|
||||
...field,
|
||||
description: `Claim containing per-user permissions JSON. Sample: ${User.getSampleAbsPermissions()}`
|
||||
samplePermissions: User.getSampleAbsPermissions()
|
||||
}
|
||||
}
|
||||
return field
|
||||
|
|
|
|||
|
|
@ -157,9 +157,10 @@ class TokenManager {
|
|||
* @param {{ id:string, username:string }} user
|
||||
* @param {import('express').Request} req
|
||||
* @param {string|null} [oidcIdToken=null] - OIDC id_token to store in session for logout
|
||||
* @param {string|null} [oidcSessionId=null] - OIDC session ID (sid claim) for backchannel logout
|
||||
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}
|
||||
*/
|
||||
async createTokensAndSession(user, req, oidcIdToken = null) {
|
||||
async createTokensAndSession(user, req, oidcIdToken = null, oidcSessionId = null) {
|
||||
const ipAddress = requestIp.getClientIp(req)
|
||||
const userAgent = req.headers['user-agent']
|
||||
const accessToken = this.generateTempAccessToken(user)
|
||||
|
|
@ -168,7 +169,7 @@ class TokenManager {
|
|||
// Calculate expiration time for the refresh token
|
||||
const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
||||
|
||||
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken)
|
||||
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken, oidcSessionId)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue