mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-03 06: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
|
|
@ -3,7 +3,7 @@
|
|||
<p v-if="label" class="text-sm font-semibold px-1 mb-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<div v-for="(entry, index) in entries" :key="index" class="flex items-center gap-2 mb-2">
|
||||
<div class="flex-grow">
|
||||
<input type="text" :value="entry.key" :disabled="disabled" class="w-full rounded-sm bg-primary border border-gray-600 text-sm px-3 py-2" placeholder="Group name" @input="updateKey(index, $event.target.value)" />
|
||||
<input type="text" :value="entry.key" :disabled="disabled" class="w-full rounded-sm bg-primary text-sm px-3 py-2" :class="isDuplicateKey(entry.key, index) ? 'border border-warning' : 'border border-gray-600'" :title="isDuplicateKey(entry.key, index) ? 'Duplicate group name' : ''" placeholder="Group name" @input="updateKey(index, $event.target.value)" />
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<select :value="entry.value" :disabled="disabled" class="w-full rounded-sm bg-primary border border-gray-600 text-sm px-2 py-2" @change="updateValue(index, $event.target.value)">
|
||||
|
|
@ -14,7 +14,8 @@
|
|||
<span class="material-symbols text-xl">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" :disabled="disabled" class="flex items-center text-sm text-gray-300 hover:text-white" @click="addEntry">
|
||||
<p v-if="hasDuplicates" class="text-warning text-xs px-1 mb-1">Duplicate group names — only the last entry will be kept</p>
|
||||
<button type="button" :disabled="disabled" class="flex items-center text-sm text-gray-300 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed" @click="addEntry">
|
||||
<span class="material-symbols text-lg mr-1">add</span>
|
||||
<span>Add mapping</span>
|
||||
</button>
|
||||
|
|
@ -40,15 +41,33 @@ export default {
|
|||
entries: Object.entries(this.value || {}).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasDuplicates() {
|
||||
const keys = this.entries.map((e) => e.key).filter((k) => k)
|
||||
return new Set(keys).size !== keys.length
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
handler(newVal) {
|
||||
this.entries = Object.entries(newVal || {}).map(([key, value]) => ({ key, value }))
|
||||
// Only rebuild entries if the prop differs from what local state would emit.
|
||||
// This prevents re-rendering (and closing dropdowns) when our own emit echoes back.
|
||||
const currentOutput = {}
|
||||
for (const entry of this.entries) {
|
||||
if (entry.key) currentOutput[entry.key] = entry.value
|
||||
}
|
||||
if (JSON.stringify(newVal || {}) !== JSON.stringify(currentOutput)) {
|
||||
this.entries = Object.entries(newVal || {}).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isDuplicateKey(key, index) {
|
||||
if (!key) return false
|
||||
return this.entries.some((e, i) => i !== index && e.key === key)
|
||||
},
|
||||
emitUpdate() {
|
||||
const obj = {}
|
||||
for (const entry of this.entries) {
|
||||
|
|
@ -72,7 +91,6 @@ export default {
|
|||
},
|
||||
addEntry() {
|
||||
this.entries.push({ key: '', value: this.valueOptions[0] || '' })
|
||||
this.emitUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,25 @@
|
|||
<div class="w-full">
|
||||
<div v-for="group in sortedGroups" :key="group.id" class="mb-4">
|
||||
<p class="text-sm font-semibold text-gray-200 uppercase tracking-wide mb-2 px-1">{{ group.label }}</p>
|
||||
<p v-if="getGroupDescription(group)" class="text-sm text-gray-300 mb-2 px-1">{{ getGroupDescription(group) }}</p>
|
||||
<div class="flex flex-wrap">
|
||||
<template v-for="field in fieldsForGroup(group.id)">
|
||||
<!-- Action button (e.g., Auto-populate) -->
|
||||
<div v-if="field.type === 'action'" :key="field.key" class="w-36 mx-1 mt-[1.375rem] mb-2">
|
||||
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" :disabled="isFieldDisabled(field)" @click.stop="$emit('action', field.key)">
|
||||
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" :disabled="isFieldDisabled(field)" :loading="loadingActions.includes(field.key)" @click.stop="$emit('action', field.key)">
|
||||
<span class="material-symbols text-base">auto_fix_high</span>
|
||||
<span class="whitespace-nowrap break-keep pl-1">{{ field.label }}</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
<!-- Text input -->
|
||||
<ui-text-input-with-label v-else-if="field.type === 'text'" :key="field.key" :value="values[field.key]" :disabled="disabled || isFieldDisabled(field)" :label="field.label" class="mb-2" @input="onFieldChange(field.key, $event)" />
|
||||
<div v-else-if="field.type === 'text'" :key="field.key" class="w-full mb-2">
|
||||
<ui-text-input-with-label :value="values[field.key]" :disabled="disabled || isFieldDisabled(field)" :label="field.label" @input="onFieldChange(field.key, $event)" />
|
||||
<div v-if="getFieldHtmlDescription(field)" class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300">
|
||||
<p v-html="getFieldHtmlDescription(field)"></p>
|
||||
<pre v-if="field.samplePermissions" class="text-pre-wrap mt-2">{{ field.samplePermissions }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password input -->
|
||||
<ui-text-input-with-label v-else-if="field.type === 'password'" :key="field.key" :value="values[field.key]" :disabled="disabled || isFieldDisabled(field)" :label="field.label" type="password" class="mb-2" @input="onFieldChange(field.key, $event)" />
|
||||
|
|
@ -22,7 +29,7 @@
|
|||
<div v-else-if="field.type === 'boolean'" :key="field.key" class="flex items-center py-4 px-1 w-full">
|
||||
<ui-toggle-switch :value="!!values[field.key]" :disabled="disabled || isFieldDisabled(field)" @input="onFieldChange(field.key, $event)" />
|
||||
<p class="pl-4 whitespace-nowrap">{{ field.label }}</p>
|
||||
<p v-if="field.description" class="pl-4 text-sm text-gray-300">{{ field.description }}</p>
|
||||
<p v-if="field.description" class="pl-4 text-sm text-gray-300">{{ resolveDescription(field.description) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Select dropdown -->
|
||||
|
|
@ -69,7 +76,11 @@ export default {
|
|||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
loadingActions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedGroups() {
|
||||
|
|
@ -94,6 +105,19 @@ export default {
|
|||
value: opt.value
|
||||
}))
|
||||
},
|
||||
getGroupDescription(group) {
|
||||
if (group.descriptionKey) return this.$strings[group.descriptionKey] || ''
|
||||
return group.description || ''
|
||||
},
|
||||
getFieldHtmlDescription(field) {
|
||||
if (field.descriptionKey) return this.$strings[field.descriptionKey] || ''
|
||||
return field.description || ''
|
||||
},
|
||||
resolveDescription(desc) {
|
||||
if (!desc || !desc.includes('{baseURL}')) return desc
|
||||
const baseURL = window.location.origin + this.$config.routerBasePath
|
||||
return desc.replace('{baseURL}', baseURL)
|
||||
},
|
||||
onFieldChange(key, value) {
|
||||
this.$emit('update', { key, value })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -364,6 +364,14 @@ export default {
|
|||
adminMessageEvt(message) {
|
||||
this.$toast.info(message)
|
||||
},
|
||||
backchannelLogout() {
|
||||
console.log('[SOCKET] Backchannel logout received from identity provider')
|
||||
this.$toast.warning(this.$strings.ToastSessionEndedByProvider, { timeout: 5000 })
|
||||
// Use a timeout so the toast is visible before redirect
|
||||
setTimeout(() => {
|
||||
window.location.replace(`${this.$config.routerBasePath}/login`)
|
||||
}, 1000)
|
||||
},
|
||||
ereaderDevicesUpdated(data) {
|
||||
if (!data?.ereaderDevices) return
|
||||
|
||||
|
|
@ -474,6 +482,9 @@ export default {
|
|||
|
||||
this.socket.on('admin_message', this.adminMessageEvt)
|
||||
|
||||
// OIDC Back-Channel Logout
|
||||
this.socket.on('backchannel_logout', this.backchannelLogout)
|
||||
|
||||
// Custom metadata provider Listeners
|
||||
this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded)
|
||||
this.socket.on('custom_metadata_provider_removed', this.customMetadataProviderRemoved)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<transition name="slide">
|
||||
<div v-if="enableOpenIDAuth" class="pt-4">
|
||||
<app-oidc-settings :schema="openIDSchema" :groups="openIDGroups" :values="openIDValues" :schema-overrides="openIDSchemaOverrides" :disabled="savingSettings" @update="onOidcSettingChange" @action="onOidcAction" />
|
||||
<app-oidc-settings :schema="openIDSchema" :groups="openIDGroups" :values="openIDValues" :schema-overrides="openIDSchemaOverrides" :disabled="savingSettings" :loading-actions="discovering ? ['discover'] : []" @update="onOidcSettingChange" @action="onOidcAction" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
|
@ -69,6 +69,7 @@ export default {
|
|||
enableOpenIDAuth: false,
|
||||
showCustomLoginMessage: false,
|
||||
savingSettings: false,
|
||||
discovering: false,
|
||||
openIDSchemaOverrides: {},
|
||||
newAuthSettings: {},
|
||||
openIDValues: {}
|
||||
|
|
@ -110,6 +111,7 @@ export default {
|
|||
this.$set(this.openIDValues, 'authOpenIDIssuerURL', issuerUrl)
|
||||
}
|
||||
|
||||
this.discovering = true
|
||||
try {
|
||||
const data = await this.$axios.$post('/api/auth-settings/openid/discover', { issuerUrl })
|
||||
|
||||
|
|
@ -122,14 +124,18 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
// Apply schema overrides (e.g., supported signing algorithms)
|
||||
// Merge schema overrides (e.g., supported signing algorithms) with existing ones
|
||||
if (data.schemaOverrides) {
|
||||
this.openIDSchemaOverrides = data.schemaOverrides
|
||||
this.openIDSchemaOverrides = { ...this.openIDSchemaOverrides, ...data.schemaOverrides }
|
||||
}
|
||||
|
||||
this.$toast.success('Provider endpoints auto-populated')
|
||||
} catch (error) {
|
||||
console.error('Failed to discover OIDC config', error)
|
||||
const errorMsg = error.response?.data?.error || error.response?.data || 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
} finally {
|
||||
this.discovering = false
|
||||
}
|
||||
},
|
||||
async saveSettings() {
|
||||
|
|
@ -185,6 +191,16 @@ export default {
|
|||
authOpenIDSubfolderForRedirectURLs: serverValues.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : serverValues.authOpenIDSubfolderForRedirectURLs
|
||||
}
|
||||
|
||||
// Build subfolder dropdown options from routerBasePath
|
||||
const basePath = this.$config.routerBasePath
|
||||
const subfolderOptions = [{ value: '', label: 'None' }]
|
||||
if (basePath && basePath !== '/') {
|
||||
subfolderOptions.push({ value: basePath, label: basePath })
|
||||
}
|
||||
this.openIDSchemaOverrides = {
|
||||
authOpenIDSubfolderForRedirectURLs: { options: subfolderOptions }
|
||||
}
|
||||
|
||||
this.enableLocalAuth = this.authMethods.includes('local')
|
||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||
this.showCustomLoginMessage = !!this.authSettings.authLoginCustomMessage
|
||||
|
|
|
|||
|
|
@ -1131,6 +1131,7 @@
|
|||
"ToastSeriesUpdateFailed": "Series update failed",
|
||||
"ToastSeriesUpdateSuccess": "Series update success",
|
||||
"ToastServerSettingsUpdateSuccess": "Server settings updated",
|
||||
"ToastSessionEndedByProvider": "Session ended by identity provider",
|
||||
"ToastSessionCloseFailed": "Failed to close session",
|
||||
"ToastSessionDeleteFailed": "Failed to delete session",
|
||||
"ToastSessionDeleteSuccess": "Session deleted",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
"lru-cache": "^10.0.3",
|
||||
"node-unrar-js": "^2.0.2",
|
||||
"nodemailer": "^6.9.13",
|
||||
"jose": "^4.15.4",
|
||||
"openid-client": "^5.6.1",
|
||||
"p-throttle": "^4.1.1",
|
||||
"passport": "^0.6.0",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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')
|
||||
|
|
@ -26,6 +27,7 @@ class Auth {
|
|||
this.tokenManager = new TokenManager()
|
||||
this.localAuthStrategy = new LocalAuthStrategy()
|
||||
this.oidcAuthStrategy = new OidcAuthStrategy()
|
||||
this.backchannelLogoutHandler = new BackchannelLogoutHandler()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -167,6 +169,7 @@ class Auth {
|
|||
unuseAuthStrategy(name) {
|
||||
if (name === 'openid') {
|
||||
this.oidcAuthStrategy.reload()
|
||||
this.backchannelLogoutHandler.reset()
|
||||
} else if (name === 'local') {
|
||||
this.localAuthStrategy.unuse()
|
||||
} else {
|
||||
|
|
@ -182,6 +185,7 @@ class Auth {
|
|||
useAuthStrategy(name) {
|
||||
if (name === 'openid') {
|
||||
this.oidcAuthStrategy.reload()
|
||||
this.backchannelLogoutHandler.reset()
|
||||
} else if (name === 'local') {
|
||||
this.localAuthStrategy.init()
|
||||
} else {
|
||||
|
|
@ -311,21 +315,30 @@ class Auth {
|
|||
|
||||
// openid strategy callback route - now uses direct token exchange (no passport)
|
||||
router.get('/auth/openid/callback', this.authRateLimiter, async (req, res) => {
|
||||
const isMobile = !!req.session.oidc?.isMobile
|
||||
// Extract session data before cleanup (needed for redirect on success)
|
||||
// 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 = await this.oidcAuthStrategy.handleCallback(req)
|
||||
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 in DB
|
||||
// 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)
|
||||
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
|
||||
|
|
@ -362,7 +375,8 @@ class Auth {
|
|||
res.redirect(`${global.RouterBasePath}/login?error=${encodeURIComponent(error.message)}&autoLaunch=0`)
|
||||
}
|
||||
} finally {
|
||||
// Clean up OIDC session data to prevent replay (on both success and error paths)
|
||||
// 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
|
||||
}
|
||||
})
|
||||
|
|
@ -425,7 +439,11 @@ class Auth {
|
|||
let logoutUrl = null
|
||||
|
||||
if (authMethod === 'openid' || authMethod === 'openid-mobile') {
|
||||
logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, oidcIdToken, authMethod)
|
||||
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
|
||||
|
|
@ -434,6 +452,31 @@ class Auth {
|
|||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
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,
|
||||
|
|
|
|||
127
server/migrations/v2.34.0-backchannel-logout.js
Normal file
127
server/migrations/v2.34.0-backchannel-logout.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* @typedef MigrationContext
|
||||
* @property {import('sequelize').QueryInterface} queryInterface - a Sequelize QueryInterface object.
|
||||
* @property {import('../Logger')} logger - a Logger object.
|
||||
*
|
||||
* @typedef MigrationOptions
|
||||
* @property {MigrationContext} context - an object containing the migration context.
|
||||
*/
|
||||
|
||||
const migrationVersion = '2.34.0'
|
||||
const migrationName = `${migrationVersion}-backchannel-logout`
|
||||
const loggerPrefix = `[${migrationVersion} migration]`
|
||||
|
||||
/**
|
||||
* This migration adds oidcSessionId column to sessions table and
|
||||
* authOpenIDBackchannelLogoutEnabled to server settings.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function up({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Add oidcSessionId column to sessions table
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
const tableDescription = await queryInterface.describeTable('sessions')
|
||||
if (!tableDescription.oidcSessionId) {
|
||||
logger.info(`${loggerPrefix} Adding oidcSessionId column to sessions table`)
|
||||
await queryInterface.addColumn('sessions', 'oidcSessionId', {
|
||||
type: queryInterface.sequelize.Sequelize.DataTypes.STRING,
|
||||
allowNull: true
|
||||
})
|
||||
logger.info(`${loggerPrefix} Added oidcSessionId column to sessions table`)
|
||||
// Add index for backchannel logout lookups by oidcSessionId
|
||||
await queryInterface.addIndex('sessions', ['oidcSessionId'], {
|
||||
name: 'sessions_oidc_session_id'
|
||||
})
|
||||
logger.info(`${loggerPrefix} Added index on oidcSessionId column`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} oidcSessionId column already exists in sessions table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} sessions table does not exist`)
|
||||
}
|
||||
|
||||
// Initialize authOpenIDBackchannelLogoutEnabled in server settings
|
||||
const serverSettings = await getServerSettings(queryInterface, logger)
|
||||
|
||||
if (serverSettings.authOpenIDBackchannelLogoutEnabled === undefined) {
|
||||
serverSettings.authOpenIDBackchannelLogoutEnabled = false
|
||||
logger.info(`${loggerPrefix} Initialized authOpenIDBackchannelLogoutEnabled to false`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} authOpenIDBackchannelLogoutEnabled already exists in server settings`)
|
||||
}
|
||||
|
||||
await updateServerSettings(queryInterface, logger, serverSettings)
|
||||
|
||||
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* This migration removes oidcSessionId column from sessions table and
|
||||
* removes authOpenIDBackchannelLogoutEnabled from server settings.
|
||||
*
|
||||
* @param {MigrationOptions} options - an object containing the migration context.
|
||||
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
|
||||
*/
|
||||
async function down({ context: { queryInterface, logger } }) {
|
||||
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
|
||||
|
||||
// Remove oidcSessionId column from sessions table
|
||||
if (await queryInterface.tableExists('sessions')) {
|
||||
const tableDescription = await queryInterface.describeTable('sessions')
|
||||
if (tableDescription.oidcSessionId) {
|
||||
logger.info(`${loggerPrefix} Removing oidcSessionId index and column from sessions table`)
|
||||
try {
|
||||
await queryInterface.removeIndex('sessions', 'sessions_oidc_session_id')
|
||||
} catch {
|
||||
logger.info(`${loggerPrefix} Index sessions_oidc_session_id did not exist`)
|
||||
}
|
||||
await queryInterface.removeColumn('sessions', 'oidcSessionId')
|
||||
logger.info(`${loggerPrefix} Removed oidcSessionId column from sessions table`)
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} oidcSessionId column does not exist in sessions table`)
|
||||
}
|
||||
} else {
|
||||
logger.info(`${loggerPrefix} sessions table does not exist`)
|
||||
}
|
||||
|
||||
// Remove authOpenIDBackchannelLogoutEnabled from server settings
|
||||
const serverSettings = await getServerSettings(queryInterface, logger)
|
||||
if (serverSettings.authOpenIDBackchannelLogoutEnabled !== undefined) {
|
||||
delete serverSettings.authOpenIDBackchannelLogoutEnabled
|
||||
await updateServerSettings(queryInterface, logger, serverSettings)
|
||||
logger.info(`${loggerPrefix} Removed authOpenIDBackchannelLogoutEnabled from server settings`)
|
||||
}
|
||||
|
||||
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
|
||||
}
|
||||
|
||||
async function getServerSettings(queryInterface, logger) {
|
||||
const result = await queryInterface.sequelize.query('SELECT value FROM settings WHERE key = "server-settings";')
|
||||
if (!result[0].length) {
|
||||
logger.error(`${loggerPrefix} Server settings not found`)
|
||||
throw new Error('Server settings not found')
|
||||
}
|
||||
|
||||
let serverSettings = null
|
||||
try {
|
||||
serverSettings = JSON.parse(result[0][0].value)
|
||||
} catch (error) {
|
||||
logger.error(`${loggerPrefix} Error parsing server settings:`, error)
|
||||
throw error
|
||||
}
|
||||
|
||||
return serverSettings
|
||||
}
|
||||
|
||||
async function updateServerSettings(queryInterface, logger, serverSettings) {
|
||||
await queryInterface.sequelize.query('UPDATE settings SET value = :value WHERE key = "server-settings";', {
|
||||
replacements: {
|
||||
value: JSON.stringify(serverSettings)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { up, down }
|
||||
|
|
@ -20,6 +20,8 @@ class Session extends Model {
|
|||
this.expiresAt
|
||||
/** @type {string} */
|
||||
this.oidcIdToken
|
||||
/** @type {string} */
|
||||
this.oidcSessionId
|
||||
|
||||
// Expanded properties
|
||||
|
||||
|
|
@ -27,8 +29,8 @@ class Session extends Model {
|
|||
this.user
|
||||
}
|
||||
|
||||
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken = null) {
|
||||
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken })
|
||||
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken = null, oidcSessionId = null) {
|
||||
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken, oidcSessionId })
|
||||
return session
|
||||
}
|
||||
|
||||
|
|
@ -69,11 +71,18 @@ class Session extends Model {
|
|||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
oidcIdToken: DataTypes.TEXT
|
||||
oidcIdToken: DataTypes.TEXT,
|
||||
oidcSessionId: DataTypes.STRING
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
modelName: 'session'
|
||||
modelName: 'session',
|
||||
indexes: [
|
||||
{
|
||||
name: 'sessions_oidc_session_id',
|
||||
fields: ['oidcSessionId']
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ class ServerSettings {
|
|||
this.authOpenIDScopes = 'openid profile email'
|
||||
this.authOpenIDGroupMap = {}
|
||||
this.authOpenIDRequireVerifiedEmail = false
|
||||
this.authOpenIDBackchannelLogoutEnabled = false
|
||||
|
||||
if (settings) {
|
||||
this.construct(settings)
|
||||
|
|
@ -152,6 +153,7 @@ class ServerSettings {
|
|||
this.authOpenIDScopes = settings.authOpenIDScopes || 'openid profile email'
|
||||
this.authOpenIDGroupMap = settings.authOpenIDGroupMap || {}
|
||||
this.authOpenIDRequireVerifiedEmail = !!settings.authOpenIDRequireVerifiedEmail
|
||||
this.authOpenIDBackchannelLogoutEnabled = !!settings.authOpenIDBackchannelLogoutEnabled
|
||||
|
||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||
this.authActiveAuthMethods = ['local']
|
||||
|
|
@ -264,7 +266,8 @@ class ServerSettings {
|
|||
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
|
||||
authOpenIDScopes: this.authOpenIDScopes,
|
||||
authOpenIDGroupMap: this.authOpenIDGroupMap,
|
||||
authOpenIDRequireVerifiedEmail: this.authOpenIDRequireVerifiedEmail
|
||||
authOpenIDRequireVerifiedEmail: this.authOpenIDRequireVerifiedEmail,
|
||||
authOpenIDBackchannelLogoutEnabled: this.authOpenIDBackchannelLogoutEnabled
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -317,7 +320,8 @@ class ServerSettings {
|
|||
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
|
||||
authOpenIDScopes: this.authOpenIDScopes,
|
||||
authOpenIDGroupMap: this.authOpenIDGroupMap,
|
||||
authOpenIDRequireVerifiedEmail: this.authOpenIDRequireVerifiedEmail
|
||||
authOpenIDRequireVerifiedEmail: this.authOpenIDRequireVerifiedEmail,
|
||||
authOpenIDBackchannelLogoutEnabled: this.authOpenIDBackchannelLogoutEnabled
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
319
test/server/auth/BackchannelLogoutHandler.test.js
Normal file
319
test/server/auth/BackchannelLogoutHandler.test.js
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
|
||||
describe('BackchannelLogoutHandler', function () {
|
||||
let BackchannelLogoutHandler, handler
|
||||
let joseStub, DatabaseStub, SocketAuthorityStub
|
||||
|
||||
const BACKCHANNEL_EVENT = 'http://schemas.openid.net/event/backchannel-logout'
|
||||
|
||||
beforeEach(function () {
|
||||
// Clear require cache so we get fresh stubs each test
|
||||
delete require.cache[require.resolve('../../../server/auth/BackchannelLogoutHandler')]
|
||||
|
||||
// Stub jose
|
||||
joseStub = {
|
||||
createRemoteJWKSet: sinon.stub().returns('jwks-function'),
|
||||
jwtVerify: sinon.stub()
|
||||
}
|
||||
|
||||
// Stub Database
|
||||
DatabaseStub = {
|
||||
sessionModel: {
|
||||
destroy: sinon.stub().resolves(1)
|
||||
},
|
||||
userModel: {
|
||||
getUserByOpenIDSub: sinon.stub()
|
||||
}
|
||||
}
|
||||
|
||||
// Stub SocketAuthority
|
||||
SocketAuthorityStub = {
|
||||
clientEmitter: sinon.stub()
|
||||
}
|
||||
|
||||
// Set up global.ServerSettings
|
||||
global.ServerSettings = {
|
||||
authOpenIDJwksURL: 'https://idp.example.com/.well-known/jwks.json',
|
||||
authOpenIDIssuerURL: 'https://idp.example.com',
|
||||
authOpenIDClientID: 'my-client-id'
|
||||
}
|
||||
|
||||
// Use proxyquire-style: intercept requires by replacing module cache entries
|
||||
const Module = require('module')
|
||||
const originalResolve = Module._resolveFilename
|
||||
const stubs = {
|
||||
jose: joseStub,
|
||||
'../Logger': { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), debug: sinon.stub() },
|
||||
'../Database': DatabaseStub,
|
||||
'../SocketAuthority': SocketAuthorityStub
|
||||
}
|
||||
|
||||
// Pre-populate the require cache with stubs
|
||||
const path = require('path')
|
||||
const handlerPath = require.resolve('../../../server/auth/BackchannelLogoutHandler')
|
||||
|
||||
// We need to stub the dependencies before requiring the handler
|
||||
// Clear any cached versions of the dependencies
|
||||
const josePath = require.resolve('jose')
|
||||
const loggerPath = require.resolve('../../../server/Logger')
|
||||
const databasePath = require.resolve('../../../server/Database')
|
||||
const socketPath = require.resolve('../../../server/SocketAuthority')
|
||||
|
||||
// Save original modules
|
||||
const originalJose = require.cache[josePath]
|
||||
const originalLogger = require.cache[loggerPath]
|
||||
const originalDatabase = require.cache[databasePath]
|
||||
const originalSocket = require.cache[socketPath]
|
||||
|
||||
// Replace with stubs
|
||||
require.cache[josePath] = { id: josePath, exports: joseStub }
|
||||
require.cache[loggerPath] = { id: loggerPath, exports: stubs['../Logger'] }
|
||||
require.cache[databasePath] = { id: databasePath, exports: DatabaseStub }
|
||||
require.cache[socketPath] = { id: socketPath, exports: SocketAuthorityStub }
|
||||
|
||||
// Now require the handler
|
||||
BackchannelLogoutHandler = require('../../../server/auth/BackchannelLogoutHandler')
|
||||
handler = new BackchannelLogoutHandler()
|
||||
|
||||
// Store originals for cleanup
|
||||
this._originals = { josePath, loggerPath, databasePath, socketPath, originalJose, originalLogger, originalDatabase, originalSocket }
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
// Restore original modules
|
||||
const { josePath, loggerPath, databasePath, socketPath, originalJose, originalLogger, originalDatabase, originalSocket } = this._originals
|
||||
if (originalJose) require.cache[josePath] = originalJose
|
||||
else delete require.cache[josePath]
|
||||
if (originalLogger) require.cache[loggerPath] = originalLogger
|
||||
else delete require.cache[loggerPath]
|
||||
if (originalDatabase) require.cache[databasePath] = originalDatabase
|
||||
else delete require.cache[databasePath]
|
||||
if (originalSocket) require.cache[socketPath] = originalSocket
|
||||
else delete require.cache[socketPath]
|
||||
|
||||
delete require.cache[require.resolve('../../../server/auth/BackchannelLogoutHandler')]
|
||||
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('should destroy all user sessions for sub-only token', async function () {
|
||||
const mockUser = { id: 'user-123', username: 'testuser' }
|
||||
DatabaseStub.userModel.getUserByOpenIDSub.resolves(mockUser)
|
||||
DatabaseStub.sessionModel.destroy.resolves(2)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-1',
|
||||
sub: 'oidc-sub-value',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.firstCall.args[0]).to.deep.equal({ where: { userId: 'user-123' } })
|
||||
expect(SocketAuthorityStub.clientEmitter.calledOnce).to.be.true
|
||||
expect(SocketAuthorityStub.clientEmitter.firstCall.args).to.deep.equal(['user-123', 'backchannel_logout', {}])
|
||||
})
|
||||
|
||||
it('should destroy session by sid for sid-only token', async function () {
|
||||
DatabaseStub.sessionModel.destroy.resolves(1)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-2',
|
||||
sid: 'session-abc',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.firstCall.args[0]).to.deep.equal({ where: { oidcSessionId: 'session-abc' } })
|
||||
// No sub means no user lookup and no socket notification
|
||||
expect(DatabaseStub.userModel.getUserByOpenIDSub.called).to.be.false
|
||||
expect(SocketAuthorityStub.clientEmitter.called).to.be.false
|
||||
})
|
||||
|
||||
it('should destroy by sid and notify by sub when both present', async function () {
|
||||
const mockUser = { id: 'user-456', username: 'testuser2' }
|
||||
DatabaseStub.userModel.getUserByOpenIDSub.resolves(mockUser)
|
||||
DatabaseStub.sessionModel.destroy.resolves(1)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-3',
|
||||
sub: 'oidc-sub-value',
|
||||
sid: 'session-xyz',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.true
|
||||
// Should destroy by sid (first call) and NOT destroy by userId (sid takes priority)
|
||||
expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.firstCall.args[0]).to.deep.equal({ where: { oidcSessionId: 'session-xyz' } })
|
||||
// But should still notify the user
|
||||
expect(SocketAuthorityStub.clientEmitter.calledOnce).to.be.true
|
||||
expect(SocketAuthorityStub.clientEmitter.firstCall.args[0]).to.equal('user-456')
|
||||
})
|
||||
|
||||
it('should return error for invalid JWT signature', async function () {
|
||||
joseStub.jwtVerify.rejects(new Error('JWS signature verification failed'))
|
||||
|
||||
const result = await handler.processLogoutToken('invalid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return error for missing events claim', async function () {
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
sub: 'oidc-sub-value'
|
||||
// no events
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return error for wrong events claim value', async function () {
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
sub: 'oidc-sub-value',
|
||||
events: { 'http://some-other-event': {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return error when token is missing jti claim', async function () {
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
sub: 'oidc-sub-value',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
// no jti
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return error when token contains nonce', async function () {
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-4',
|
||||
sub: 'oidc-sub-value',
|
||||
nonce: 'some-nonce',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return error when token has neither sub nor sid', async function () {
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-5',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return success for unknown sub (no user found)', async function () {
|
||||
DatabaseStub.userModel.getUserByOpenIDSub.resolves(null)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-6',
|
||||
sub: 'unknown-sub',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
// Per spec, unknown sub is not an error
|
||||
expect(result.success).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.called).to.be.false
|
||||
expect(SocketAuthorityStub.clientEmitter.called).to.be.false
|
||||
})
|
||||
|
||||
it('should reject replayed jti', async function () {
|
||||
const mockUser = { id: 'user-123', username: 'testuser' }
|
||||
DatabaseStub.userModel.getUserByOpenIDSub.resolves(mockUser)
|
||||
DatabaseStub.sessionModel.destroy.resolves(1)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'same-jti',
|
||||
sub: 'oidc-sub-value',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
// First call should succeed
|
||||
const result1 = await handler.processLogoutToken('valid.jwt.token')
|
||||
expect(result1.success).to.be.true
|
||||
|
||||
// Second call with same jti should be rejected
|
||||
const result2 = await handler.processLogoutToken('valid.jwt.token')
|
||||
expect(result2.success).to.be.false
|
||||
expect(result2.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should warn when sid destroy matches 0 sessions', async function () {
|
||||
DatabaseStub.sessionModel.destroy.resolves(0)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-warn',
|
||||
sid: 'old-session-id',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it('should reset cached JWKS and jti cache', function () {
|
||||
// Call _getJwks to cache
|
||||
handler._getJwks()
|
||||
expect(joseStub.createRemoteJWKSet.calledOnce).to.be.true
|
||||
|
||||
// Reset and call again
|
||||
handler.reset()
|
||||
handler._getJwks()
|
||||
expect(joseStub.createRemoteJWKSet.calledTwice).to.be.true
|
||||
})
|
||||
})
|
||||
|
|
@ -2,13 +2,15 @@ const { expect } = require('chai')
|
|||
const sinon = require('sinon')
|
||||
const AuthError = require('../../../server/auth/AuthError')
|
||||
|
||||
// We test setUserGroup in isolation by creating a minimal instance
|
||||
// and stubbing the globals it depends on
|
||||
// Test the real OidcAuthStrategy.setUserGroup method by stubbing its dependencies
|
||||
describe('OidcAuthStrategy - setUserGroup', function () {
|
||||
let OidcAuthStrategy, strategy
|
||||
let DatabaseStub
|
||||
|
||||
beforeEach(function () {
|
||||
// Clear require cache so we get fresh stubs each test
|
||||
delete require.cache[require.resolve('../../../server/auth/OidcAuthStrategy')]
|
||||
|
||||
before(function () {
|
||||
// Stub global dependencies that OidcAuthStrategy requires at import time
|
||||
global.ServerSettings = {
|
||||
authOpenIDGroupClaim: '',
|
||||
authOpenIDGroupMap: {},
|
||||
|
|
@ -16,71 +18,52 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
isOpenIDAuthSettingsValid: false,
|
||||
authOpenIDMobileRedirectURIs: []
|
||||
}
|
||||
// Stub Database to avoid requiring sequelize
|
||||
const Database = { serverSettings: global.ServerSettings }
|
||||
const mod = require('module')
|
||||
const originalResolve = mod._resolveFilename
|
||||
// We need to require the actual file, but it imports Database and Logger
|
||||
// Use proxyquire-style approach: clear cache and provide stubs
|
||||
})
|
||||
|
||||
beforeEach(function () {
|
||||
// Create a fresh instance for each test by directly constructing the class
|
||||
// Since the module has complex imports, we test the logic directly
|
||||
strategy = {
|
||||
setUserGroup: async function (user, userinfo) {
|
||||
const groupClaimName = global.ServerSettings.authOpenIDGroupClaim
|
||||
if (!groupClaimName) return
|
||||
|
||||
if (!userinfo[groupClaimName]) throw new AuthError(`Group claim ${groupClaimName} not found in userinfo`, 401)
|
||||
|
||||
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
||||
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||
const groupMap = global.ServerSettings.authOpenIDGroupMap || {}
|
||||
|
||||
let userType = null
|
||||
|
||||
if (Object.keys(groupMap).length > 0) {
|
||||
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 {
|
||||
userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
||||
}
|
||||
|
||||
if (userType) {
|
||||
if (user.type === 'root') {
|
||||
if (userType !== 'admin') {
|
||||
throw new AuthError(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`, 403)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (user.type !== userType) {
|
||||
user.type = userType
|
||||
await user.save()
|
||||
}
|
||||
} else {
|
||||
throw new AuthError(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`, 401)
|
||||
}
|
||||
}
|
||||
DatabaseStub = {
|
||||
serverSettings: global.ServerSettings
|
||||
}
|
||||
|
||||
const LoggerStub = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), debug: sinon.stub() }
|
||||
|
||||
// Stub dependencies in require cache
|
||||
const databasePath = require.resolve('../../../server/Database')
|
||||
const loggerPath = require.resolve('../../../server/Logger')
|
||||
|
||||
// Save originals for cleanup
|
||||
this._originals = {
|
||||
databasePath,
|
||||
loggerPath,
|
||||
originalDatabase: require.cache[databasePath],
|
||||
originalLogger: require.cache[loggerPath]
|
||||
}
|
||||
|
||||
// Replace with stubs
|
||||
require.cache[databasePath] = { id: databasePath, exports: DatabaseStub }
|
||||
require.cache[loggerPath] = { id: loggerPath, exports: LoggerStub }
|
||||
|
||||
// Now require the real class
|
||||
OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy')
|
||||
strategy = new OidcAuthStrategy()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
const { databasePath, loggerPath, originalDatabase, originalLogger } = this._originals
|
||||
if (originalDatabase) require.cache[databasePath] = originalDatabase
|
||||
else delete require.cache[databasePath]
|
||||
if (originalLogger) require.cache[loggerPath] = originalLogger
|
||||
else delete require.cache[loggerPath]
|
||||
|
||||
delete require.cache[require.resolve('../../../server/auth/OidcAuthStrategy')]
|
||||
|
||||
global.ServerSettings.authOpenIDGroupClaim = ''
|
||||
global.ServerSettings.authOpenIDGroupMap = {}
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe('legacy direct name match (empty groupMap)', function () {
|
||||
it('should assign admin role when group list includes admin', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {}
|
||||
|
||||
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||
|
|
@ -93,6 +76,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
it('should assign user role when group list includes user but not admin', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {}
|
||||
|
||||
const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() }
|
||||
|
|
@ -104,6 +88,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
it('should throw when no valid group found', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {}
|
||||
|
||||
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||
|
|
@ -123,6 +108,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
describe('explicit group mapping', function () {
|
||||
it('should map custom group names to roles', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {
|
||||
'oidc-admins': 'admin',
|
||||
'oidc-users': 'user',
|
||||
|
|
@ -138,6 +124,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
it('should prioritize admin over user', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {
|
||||
'team-leads': 'admin',
|
||||
'developers': 'user'
|
||||
|
|
@ -152,6 +139,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
it('should be case-insensitive for group matching', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {
|
||||
'MyAdmins': 'admin'
|
||||
}
|
||||
|
|
@ -165,6 +153,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
it('should throw when no mapped group matches', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {
|
||||
'admins': 'admin'
|
||||
}
|
||||
|
|
@ -185,6 +174,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
describe('root user protection', function () {
|
||||
it('should not downgrade root user to non-admin', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {}
|
||||
|
||||
const user = { type: 'root', username: 'root', save: sinon.stub().resolves() }
|
||||
|
|
@ -202,6 +192,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
it('should allow root user with admin group (no change)', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {}
|
||||
|
||||
const user = { type: 'root', username: 'root', save: sinon.stub().resolves() }
|
||||
|
|
@ -216,6 +207,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
describe('no group claim configured', function () {
|
||||
it('should do nothing when authOpenIDGroupClaim is empty', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = ''
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = ''
|
||||
|
||||
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||
const userinfo = { groups: ['admin'] }
|
||||
|
|
@ -229,6 +221,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
describe('missing group claim in userinfo', function () {
|
||||
it('should throw when group claim is not in userinfo', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
|
||||
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||
const userinfo = { email: 'test@example.com' }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue