mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 05:29:41 +00:00
149 lines
5.4 KiB
JavaScript
149 lines
5.4 KiB
JavaScript
|
|
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
|