This commit is contained in:
Denis Arnst 2026-02-24 13:49:53 +04:00 committed by GitHub
commit 58b0358600
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3371 additions and 581 deletions

View file

@ -0,0 +1,97 @@
<template>
<div class="w-full">
<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 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)">
<option v-for="opt in valueOptions" :key="opt" :value="opt">{{ opt }}</option>
</select>
</div>
<button type="button" :disabled="disabled" class="text-gray-400 hover:text-error p-1" @click="removeEntry(index)">
<span class="material-symbols text-xl">close</span>
</button>
</div>
<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>
</div>
</template>
<script>
export default {
props: {
value: {
type: Object,
default: () => ({})
},
valueOptions: {
type: Array,
default: () => []
},
label: String,
disabled: Boolean
},
data() {
return {
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) {
// 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) {
if (entry.key) {
obj[entry.key] = entry.value
}
}
this.$emit('input', obj)
},
updateKey(index, newKey) {
this.$set(this.entries, index, { ...this.entries[index], key: newKey })
this.emitUpdate()
},
updateValue(index, newValue) {
this.$set(this.entries, index, { ...this.entries[index], value: newValue })
this.emitUpdate()
},
removeEntry(index) {
this.entries.splice(index, 1)
this.emitUpdate()
},
addEntry() {
this.entries.push({ key: '', value: this.valueOptions[0] || '' })
}
}
}
</script>

View file

@ -0,0 +1,126 @@
<template>
<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)" :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 -->
<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)" />
<!-- Boolean toggle -->
<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">{{ resolveDescription(field.description) }}</p>
</div>
<!-- Select dropdown -->
<div v-else-if="field.type === 'select'" :key="field.key" class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
<div class="w-44">
<ui-dropdown :value="values[field.key]" small :items="getDropdownItems(field)" :label="field.label" :disabled="disabled || isFieldDisabled(field)" @input="onFieldChange(field.key, $event)" />
</div>
<p v-if="field.description" class="sm:pl-4 text-sm text-gray-300 mt-2 sm:mt-5">{{ field.description }}</p>
</div>
<!-- Array (multi-select) -->
<div v-else-if="field.type === 'array'" :key="field.key" class="w-full mb-2">
<ui-multi-select :value="values[field.key] || []" :items="values[field.key] || []" :label="field.label" :disabled="disabled || isFieldDisabled(field)" :menuDisabled="true" @input="onFieldChange(field.key, $event)" />
<p v-if="field.description" class="sm:pl-4 text-sm text-gray-300 mb-2">{{ field.description }}</p>
</div>
<!-- Key-value editor -->
<div v-else-if="field.type === 'keyvalue'" :key="field.key" class="w-full mb-2">
<app-key-value-editor :value="values[field.key] || {}" :value-options="field.valueOptions || []" :label="field.label" :disabled="disabled || isFieldDisabled(field)" @input="onFieldChange(field.key, $event)" />
<p v-if="field.description" class="sm:pl-4 text-sm text-gray-300 mt-1">{{ field.description }}</p>
</div>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
schema: {
type: Array,
default: () => []
},
groups: {
type: Array,
default: () => []
},
values: {
type: Object,
default: () => ({})
},
schemaOverrides: {
type: Object,
default: () => ({})
},
disabled: Boolean,
loadingActions: {
type: Array,
default: () => []
}
},
computed: {
sortedGroups() {
return [...this.groups].sort((a, b) => a.order - b.order)
}
},
methods: {
fieldsForGroup(groupId) {
return this.schema.filter((f) => f.group === groupId).sort((a, b) => a.order - b.order)
},
isFieldDisabled(field) {
if (!field.dependsOn) return false
const depValue = this.values[field.dependsOn]
return !depValue
},
getDropdownItems(field) {
// Use schema overrides if available (e.g., from discover)
const override = this.schemaOverrides[field.key]
const options = override?.options || field.options || []
return options.map((opt) => ({
text: opt.label,
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 })
}
}
}
</script>

View file

@ -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)

View file

@ -31,99 +31,12 @@
</div>
<transition name="slide">
<div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4">
<div class="w-full flex items-center mb-2">
<div class="grow">
<ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" />
</div>
<div class="w-36 mx-1 mt-[1.375rem]">
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" @click.stop="autoPopulateOIDCClick">
<span class="material-symbols text-base">auto_fix_high</span>
<span class="whitespace-nowrap break-keep pl-1">Auto-populate</span></ui-btn
>
</div>
</div>
<ui-text-input-with-label ref="authorizationUrl" v-model="newAuthSettings.authOpenIDAuthorizationURL" :disabled="savingSettings" :label="'Authorize URL'" class="mb-2" />
<ui-text-input-with-label ref="tokenUrl" v-model="newAuthSettings.authOpenIDTokenURL" :disabled="savingSettings" :label="'Token URL'" class="mb-2" />
<ui-text-input-with-label ref="userInfoUrl" v-model="newAuthSettings.authOpenIDUserInfoURL" :disabled="savingSettings" :label="'Userinfo URL'" class="mb-2" />
<ui-text-input-with-label ref="jwksUrl" v-model="newAuthSettings.authOpenIDJwksURL" :disabled="savingSettings" :label="'JWKS URL'" class="mb-2" />
<ui-text-input-with-label ref="logoutUrl" v-model="newAuthSettings.authOpenIDLogoutURL" :disabled="savingSettings" :label="'Logout URL'" class="mb-2" />
<ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" />
<ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" />
<ui-dropdown v-if="openIdSigningAlgorithmsSupportedByIssuer.length" v-model="newAuthSettings.authOpenIDTokenSigningAlgorithm" :items="openIdSigningAlgorithmsSupportedByIssuer" :label="'Signing Algorithm'" :disabled="savingSettings" class="mb-2" />
<ui-text-input-with-label v-else ref="openidTokenSigningAlgorithm" v-model="newAuthSettings.authOpenIDTokenSigningAlgorithm" :disabled="savingSettings" :label="'Signing Algorithm'" class="mb-2" />
<ui-multi-select ref="redirectUris" v-model="newAuthSettings.authOpenIDMobileRedirectURIs" :items="newAuthSettings.authOpenIDMobileRedirectURIs" :label="$strings.LabelMobileRedirectURIs" class="mb-2" :menuDisabled="true" :disabled="savingSettings" />
<p class="sm:pl-4 text-sm text-gray-300 mb-2" v-html="$strings.LabelMobileRedirectURIsDescription" />
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
<div class="w-44">
<ui-dropdown v-model="newAuthSettings.authOpenIDSubfolderForRedirectURLs" small :items="subfolderOptions" :label="$strings.LabelWebRedirectURLsSubfolder" :disabled="savingSettings" />
</div>
<div class="mt-2 sm:mt-5">
<p class="sm:pl-4 text-sm text-gray-300">{{ $strings.LabelWebRedirectURLsDescription }}</p>
<p class="sm:pl-4 text-sm text-gray-300 mb-2">
<code>{{ webCallbackURL }}</code>
<br />
<code>{{ mobileAppCallbackURL }}</code>
</p>
</div>
</div>
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="$strings.LabelButtonText" class="mb-2" />
<div class="flex sm:items-center flex-col sm:flex-row pt-1 mb-2">
<div class="w-44">
<ui-dropdown v-model="newAuthSettings.authOpenIDMatchExistingBy" small :items="matchingExistingOptions" :label="$strings.LabelMatchExistingUsersBy" :disabled="savingSettings" />
</div>
<p class="sm:pl-4 text-sm text-gray-300 mt-2 sm:mt-5">{{ $strings.LabelMatchExistingUsersByDescription }}</p>
</div>
<div class="flex items-center py-4 px-1 w-full">
<ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" />
<p id="auto-redirect-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoLaunch }}</p>
<p class="pl-4 text-sm text-gray-300" v-html="$strings.LabelAutoLaunchDescription" />
</div>
<div class="flex items-center py-4 px-1 w-full">
<ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" />
<p id="auto-register-toggle" class="pl-4 whitespace-nowrap">{{ $strings.LabelAutoRegister }}</p>
<p class="pl-4 text-sm text-gray-300">{{ $strings.LabelAutoRegisterDescription }}</p>
</div>
<p class="pt-6 mb-4 px-1">{{ $strings.LabelOpenIDClaims }}</p>
<div class="flex flex-col sm:flex-row mb-4">
<div class="w-44 min-w-44">
<ui-text-input-with-label ref="openidGroupClaim" v-model="newAuthSettings.authOpenIDGroupClaim" :disabled="savingSettings" :placeholder="'groups'" :label="'Group Claim'" />
</div>
<p class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300" v-html="$strings.LabelOpenIDGroupClaimDescription"></p>
</div>
<div class="flex flex-col sm:flex-row mb-4">
<div class="w-44 min-w-44">
<ui-text-input-with-label ref="openidAdvancedPermsClaim" v-model="newAuthSettings.authOpenIDAdvancedPermsClaim" :disabled="savingSettings" :placeholder="'abspermissions'" :label="'Advanced Permission Claim'" />
</div>
<div class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300">
<p v-html="$strings.LabelOpenIDAdvancedPermsClaimDescription"></p>
<pre class="text-pre-wrap mt-2"
>{{ newAuthSettings.authOpenIDSamplePermissions }}
</pre>
</div>
</div>
<div v-if="enableOpenIDAuth" class="pt-4">
<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>
<div class="w-full flex items-center justify-between p-4">
<p v-if="enableOpenIDAuth" class="text-sm text-warning">{{ $strings.MessageAuthenticationOIDCChangesRestart }}</p>
<div class="w-full flex items-center justify-end p-4">
<ui-btn color="bg-success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
</div>
</app-settings-content>
@ -156,171 +69,74 @@ export default {
enableOpenIDAuth: false,
showCustomLoginMessage: false,
savingSettings: false,
openIdSigningAlgorithmsSupportedByIssuer: [],
newAuthSettings: {}
discovering: false,
openIDSchemaOverrides: {},
newAuthSettings: {},
openIDValues: {}
}
},
computed: {
authMethods() {
return this.authSettings.authActiveAuthMethods || []
},
matchingExistingOptions() {
return [
{
text: 'Do not match',
value: null
},
{
text: 'Match by email',
value: 'email'
},
{
text: 'Match by username',
value: 'username'
}
]
openIDSchema() {
return this.authSettings.openIDSettings?.schema || []
},
subfolderOptions() {
const options = [
{
text: 'None',
value: ''
}
]
if (this.$config.routerBasePath) {
options.push({
text: this.$config.routerBasePath,
value: this.$config.routerBasePath
})
}
return options
},
webCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/callback`
},
mobileAppCallbackURL() {
return `https://<your.server.com>${this.newAuthSettings.authOpenIDSubfolderForRedirectURLs ? this.newAuthSettings.authOpenIDSubfolderForRedirectURLs : ''}/auth/openid/mobile-redirect`
openIDGroups() {
return this.authSettings.openIDSettings?.groups || []
}
},
methods: {
autoPopulateOIDCClick() {
if (!this.newAuthSettings.authOpenIDIssuerURL) {
onOidcSettingChange({ key, value }) {
this.$set(this.openIDValues, key, value)
},
onOidcAction(action) {
if (action === 'discover') {
this.discoverOIDC()
}
},
async discoverOIDC() {
let issuerUrl = this.openIDValues.authOpenIDIssuerURL
if (!issuerUrl) {
this.$toast.error('Issuer URL required')
return
}
// Remove trailing slash
let issuerUrl = this.newAuthSettings.authOpenIDIssuerURL
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
// If the full config path is on the issuer url then remove it
if (issuerUrl.endsWith('/.well-known/openid-configuration')) {
issuerUrl = issuerUrl.replace('/.well-known/openid-configuration', '')
this.newAuthSettings.authOpenIDIssuerURL = this.newAuthSettings.authOpenIDIssuerURL.replace('/.well-known/openid-configuration', '')
this.$set(this.openIDValues, 'authOpenIDIssuerURL', issuerUrl)
}
const setSupportedSigningAlgorithms = (algorithms) => {
if (!algorithms?.length || !Array.isArray(algorithms)) {
console.warn('Invalid id_token_signing_alg_values_supported from openid-configuration', algorithms)
this.openIdSigningAlgorithmsSupportedByIssuer = []
return
}
this.openIdSigningAlgorithmsSupportedByIssuer = algorithms
this.discovering = true
try {
const data = await this.$axios.$post('/api/auth-settings/openid/discover', { issuerUrl })
// If a signing algorithm is already selected, then keep it, when it is still supported.
// But if it is not supported, then select one of the supported ones.
let currentAlgorithm = this.newAuthSettings.authOpenIDTokenSigningAlgorithm
if (!algorithms.includes(currentAlgorithm)) {
this.newAuthSettings.authOpenIDTokenSigningAlgorithm = algorithms[0]
}
}
this.$axios
.$get(`/auth/openid/config?issuer=${issuerUrl}`)
.then((data) => {
if (data.issuer) this.newAuthSettings.authOpenIDIssuerURL = data.issuer
if (data.authorization_endpoint) this.newAuthSettings.authOpenIDAuthorizationURL = data.authorization_endpoint
if (data.token_endpoint) this.newAuthSettings.authOpenIDTokenURL = data.token_endpoint
if (data.userinfo_endpoint) this.newAuthSettings.authOpenIDUserInfoURL = data.userinfo_endpoint
if (data.end_session_endpoint) this.newAuthSettings.authOpenIDLogoutURL = data.end_session_endpoint
if (data.jwks_uri) this.newAuthSettings.authOpenIDJwksURL = data.jwks_uri
if (data.id_token_signing_alg_values_supported) setSupportedSigningAlgorithms(data.id_token_signing_alg_values_supported)
})
.catch((error) => {
console.error('Failed to receive data', error)
const errorMsg = error.response?.data || 'Unknown error'
this.$toast.error(errorMsg)
})
},
validateOpenID() {
let isValid = true
if (!this.newAuthSettings.authOpenIDIssuerURL) {
this.$toast.error('Issuer URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDAuthorizationURL) {
this.$toast.error('Authorize URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDTokenURL) {
this.$toast.error('Token URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDUserInfoURL) {
this.$toast.error('Userinfo URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDJwksURL) {
this.$toast.error('JWKS URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDClientID) {
this.$toast.error('Client ID required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDClientSecret) {
this.$toast.error('Client Secret required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDTokenSigningAlgorithm) {
this.$toast.error('Signing Algorithm required')
isValid = false
}
function isValidRedirectURI(uri) {
// Check for somestring://someother/string
const pattern = new RegExp('^\\w+://[\\w\\.-]+(/[\\w\\./-]*)*$', 'i')
return pattern.test(uri)
}
const uris = this.newAuthSettings.authOpenIDMobileRedirectURIs
if (uris.includes('*') && uris.length > 1) {
this.$toast.error('Mobile Redirect URIs: Asterisk (*) must be the only entry if used')
isValid = false
} else {
uris.forEach((uri) => {
if (uri !== '*' && !isValidRedirectURI(uri)) {
this.$toast.error(`Mobile Redirect URIs: Invalid URI ${uri}`)
isValid = false
// Apply discovered values
if (data.values) {
for (const [key, value] of Object.entries(data.values)) {
if (value !== null && value !== undefined) {
this.$set(this.openIDValues, key, value)
}
}
})
}
}
function isValidClaim(claim) {
if (claim === '') return true
// Merge schema overrides (e.g., supported signing algorithms) with existing ones
if (data.schemaOverrides) {
this.openIDSchemaOverrides = { ...this.openIDSchemaOverrides, ...data.schemaOverrides }
}
const pattern = new RegExp('^[a-zA-Z][a-zA-Z0-9_-]*$', 'i')
return pattern.test(claim)
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
}
if (!isValidClaim(this.newAuthSettings.authOpenIDGroupClaim)) {
this.$toast.error('Group Claim: Invalid claim name')
isValid = false
}
if (!isValidClaim(this.newAuthSettings.authOpenIDAdvancedPermsClaim)) {
this.$toast.error('Advanced Permission Claim: Invalid claim name')
isValid = false
}
return isValid
},
async saveSettings() {
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
@ -328,42 +144,63 @@ export default {
return
}
if (this.enableOpenIDAuth && !this.validateOpenID()) {
return
}
if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) {
this.newAuthSettings.authLoginCustomMessage = null
}
this.newAuthSettings.authActiveAuthMethods = []
if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local')
if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid')
const authActiveAuthMethods = []
if (this.enableLocalAuth) authActiveAuthMethods.push('local')
if (this.enableOpenIDAuth) authActiveAuthMethods.push('openid')
const payload = {
authLoginCustomMessage: this.newAuthSettings.authLoginCustomMessage,
authActiveAuthMethods,
openIDSettings: this.openIDValues
}
this.savingSettings = true
this.$axios
.$patch('/api/auth-settings', this.newAuthSettings)
.then((data) => {
this.$store.commit('setServerSettings', data.serverSettings)
if (data.updated) {
this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess)
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
})
.catch((error) => {
console.error('Failed to update server settings', error)
try {
const data = await this.$axios.$patch('/api/auth-settings', payload)
this.$store.commit('setServerSettings', data.serverSettings)
if (data.updated) {
this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess)
} else {
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
}
} catch (error) {
console.error('Failed to update server settings', error)
if (error.response?.data?.details) {
error.response.data.details.forEach((detail) => this.$toast.error(detail))
} else {
this.$toast.error(this.$strings.ToastFailedToUpdate)
})
.finally(() => {
this.savingSettings = false
})
}
} finally {
this.savingSettings = false
}
},
init() {
this.newAuthSettings = {
...this.authSettings,
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
authLoginCustomMessage: this.authSettings.authLoginCustomMessage,
authActiveAuthMethods: this.authSettings.authActiveAuthMethods
}
// Initialize OIDC values from server response
const serverValues = this.authSettings.openIDSettings?.values || {}
this.openIDValues = {
...serverValues,
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

View file

@ -1134,6 +1134,7 @@
"ToastSessionCloseFailed": "Failed to close session",
"ToastSessionDeleteFailed": "Failed to delete session",
"ToastSessionDeleteSuccess": "Session deleted",
"ToastSessionEndedByProvider": "Session ended by identity provider",
"ToastSleepTimerDone": "Sleep timer done... zZzzZz",
"ToastSlugMustChange": "Slug contains invalid characters",
"ToastSlugRequired": "Slug is required",

View file

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "2.32.1",
"version": "2.34.0",
"buildNumber": 1,
"description": "Self-hosted audiobook and podcast server",
"main": "index.js",
@ -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",

View file

@ -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()
}
/**
@ -107,6 +109,7 @@ class Auth {
// #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)
@ -114,10 +117,7 @@ class Auth {
this.localAuthStrategy.init()
}
// Check if we should load the openid strategy
if (global.ServerSettings.authActiveAuthMethods.includes('openid')) {
this.oidcAuthStrategy.init()
}
// OIDC no longer needs passport initialization - it handles tokens directly
// Load the JwtStrategy (always) -> for bearer token auth
passport.use(
@ -168,7 +168,8 @@ class Auth {
*/
unuseAuthStrategy(name) {
if (name === 'openid') {
this.oidcAuthStrategy.unuse()
this.oidcAuthStrategy.reload()
this.backchannelLogoutHandler.reset()
} else if (name === 'local') {
this.localAuthStrategy.unuse()
} else {
@ -183,7 +184,8 @@ class Auth {
*/
useAuthStrategy(name) {
if (name === 'openid') {
this.oidcAuthStrategy.init()
this.oidcAuthStrategy.reload()
this.backchannelLogoutHandler.reset()
} else if (name === 'local') {
this.localAuthStrategy.init()
} else {
@ -202,84 +204,7 @@ class Auth {
}
/**
* Stores the client's choice of login callback method in temporary cookies.
*
* The `authMethod` parameter specifies the authentication strategy and can have the following values:
* - 'local': Standard authentication,
* - 'api': Authentication for API use
* - 'openid': OpenID authentication directly over web
* - 'openid-mobile': OpenID authentication, but done via an mobile device
*
* @param {Request} req
* @param {Response} res
* @param {string} authMethod - The authentication method, default is 'local'.
* @returns {Object|null} - Returns error object if validation fails, null if successful
*/
paramsToCookies(req, res, authMethod = 'local') {
const TWO_MINUTES = 120000 // 2 minutes in milliseconds
const callback = req.query.redirect_uri || req.query.callback
// Additional handling for non-API based authMethod
if (!this.isAuthMethodAPIBased(authMethod)) {
// Store 'auth_state' if present in the request
if (req.query.state) {
res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true })
}
// Validate and store the callback URL
if (!callback) {
res.status(400).send({ message: 'No callback parameter' })
return { error: 'No callback parameter' }
}
// Security: Validate callback URL is same-origin only
if (!this.oidcAuthStrategy.isValidWebCallbackUrl(callback, req)) {
Logger.warn(`[Auth] Rejected invalid callback URL: ${callback}`)
res.status(400).send({ message: 'Invalid callback URL - must be same-origin' })
return { error: 'Invalid callback URL - must be same-origin' }
}
res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true })
}
// Store the authentication method for long
Logger.debug(`[Auth] paramsToCookies: setting auth_method cookie to ${authMethod}`)
res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true })
return null
}
/**
* Informs the client in the right mode about a successfull login and the token
* (clients choise is restored from cookies).
*
* @param {Request} req
* @param {Response} res
*/
async handleLoginSuccessBasedOnCookie(req, res) {
// Handle token generation and get userResponse object
// For API based auth (e.g. mobile), we will return the refresh token in the response
const isApiBased = this.isAuthMethodAPIBased(req.cookies.auth_method)
Logger.debug(`[Auth] handleLoginSuccessBasedOnCookie: isApiBased: ${isApiBased}, auth_method: ${req.cookies.auth_method}`)
const userResponse = await this.handleLoginSuccess(req, res, isApiBased)
if (isApiBased) {
// REST request - send data
res.json(userResponse)
} else {
// UI request -> check if we have a callback url
if (req.cookies.auth_cb) {
let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : ''
// UI request -> redirect to auth_cb url and send the jwt token as parameter
// TODO: Temporarily continue sending the old token as setToken
res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`)
} else {
res.status(400).send('No callback or already expired')
}
}
}
/**
* After login success from local or oidc
* After login success from local auth
* req.user is set by passport.authenticate
*
* attaches the access token to the user in the response
@ -318,6 +243,9 @@ class Auth {
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'
@ -358,16 +286,24 @@ class Auth {
// openid strategy login route (this redirects to the configured openid login provider)
router.get('/auth/openid', this.authRateLimiter, (req, res) => {
const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req)
// 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 (authorizationUrlResponse.error) {
return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error)
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' })
}
}
// Check if paramsToCookies sent a response (e.g., due to invalid callback URL)
const cookieResult = this.paramsToCookies(req, res, authorizationUrlResponse.isMobileFlow ? 'openid-mobile' : 'openid')
if (cookieResult && cookieResult.error) {
return // Response already sent by paramsToCookies
const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req, isMobileFlow, callback)
if (authorizationUrlResponse.error) {
return res.status(authorizationUrlResponse.status).json({ error: authorizationUrlResponse.error })
}
res.redirect(authorizationUrlResponse.authorizationUrl)
@ -377,77 +313,76 @@ class Auth {
// 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 (this receives the token from the configured openid login provider)
router.get(
'/auth/openid/callback',
this.authRateLimiter,
(req, res, next) => {
const sessionKey = this.oidcAuthStrategy.getStrategy()._key
// 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
if (!req.session[sessionKey]) {
return res.status(400).send('No session')
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 the client sends us a code_verifier, we will tell passport to use this to send this in the token request
// The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
// Crucial for API/Mobile clients
if (req.query.code_verifier) {
req.session[sessionKey].code_verifier = req.query.code_verifier
}
function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
Logger.error(JSON.stringify(logMessage, null, 2))
if (response) {
// Depending on the error, it can also have a body
// We also log the request header the passport plugin sents for the URL
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2))
}
if (isMobile) {
return res.status(errorCode).send(errorMessage)
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 {
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
res.status(400).send('No callback URL')
}
}
function passportCallback(req, res, next) {
return (err, user, info) => {
const isMobile = req.session[sessionKey]?.mobile === true
if (err) {
return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
}
if (!user) {
// Info usually contains the error message from the SSO provider
return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
}
req.logIn(user, (loginError) => {
if (loginError) {
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
}
// The id_token does not provide access to the user, but is used to identify the user to the SSO provider
// instead it containts a JWT with userinfo like user email, username, etc.
// the client will get to know it anyway in the logout url according to the oauth2 spec
// so it is safe to send it to the client, but we use strict settings
res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' })
next()
})
}
} 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`)
}
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
// We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
// We set it here again because the passport param can change between requests
return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
},
// on a successfull login: read the cookies and react like the client requested (callback or json)
this.handleLoginSuccessBasedOnCookie.bind(this)
)
} 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"
*
@ -465,7 +400,7 @@ class Auth {
const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer)
if (openIdIssuerConfig.error) {
return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error)
return res.status(openIdIssuerConfig.status).json({ error: openIdIssuerConfig.error })
}
res.json(openIdIssuerConfig)
@ -473,7 +408,7 @@ class Auth {
// Logout route
router.post('/logout', async (req, res) => {
// Refresh token be alternatively be sent in the header
// Refresh token can alternatively be sent in the header
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
// Clear refresh token cookie
@ -481,8 +416,13 @@ class Auth {
path: '/'
})
// Invalidate the session in database using refresh token
// 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`)
@ -499,8 +439,11 @@ class Auth {
let logoutUrl = null
if (authMethod === 'openid' || authMethod === 'openid-mobile') {
logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod)
res.clearCookie('openid_id_token')
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
@ -509,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') || !Database.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
}

9
server/auth/AuthError.js Normal file
View file

@ -0,0 +1,9 @@
class AuthError extends Error {
constructor(message, statusCode = 500) {
super(message)
this.statusCode = statusCode
this.name = 'AuthError'
}
}
module.exports = AuthError

View 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

View file

@ -1,42 +1,20 @@
const { Request, Response } = require('express')
const passport = require('passport')
const OpenIDClient = require('openid-client')
const axios = require('axios')
const Database = require('../Database')
const Logger = require('../Logger')
const AuthError = require('./AuthError')
/**
* OpenID Connect authentication strategy
* OpenID Connect authentication strategy (no Passport wrapper)
*/
class OidcAuthStrategy {
constructor() {
this.name = 'openid-client'
this.strategy = null
this.client = null
// Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map()
}
/**
* Get the passport strategy instance
* @returns {OpenIDClient.Strategy}
*/
getStrategy() {
if (!this.strategy) {
this.strategy = new OpenIDClient.Strategy(
{
client: this.getClient(),
params: {
redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`,
scope: this.getScope()
}
},
this.verifyCallback.bind(this)
)
}
return this.strategy
}
/**
* Get the OpenID Connect client
* @returns {OpenIDClient.Client}
@ -44,7 +22,7 @@ class OidcAuthStrategy {
getClient() {
if (!this.client) {
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
throw new Error('OpenID Connect settings are not valid')
throw new AuthError('OpenID Connect settings are not valid', 500)
}
// Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
@ -73,60 +51,131 @@ class OidcAuthStrategy {
* @returns {string}
*/
getScope() {
let scope = 'openid profile email'
if (global.ServerSettings.authOpenIDGroupClaim) {
scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
}
if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
}
return scope
return global.ServerSettings.authOpenIDScopes || 'openid profile email'
}
/**
* Initialize the strategy with passport
* Reload the OIDC strategy after settings change (replaces init/unuse)
*/
init() {
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`)
return
}
passport.use(this.name, this.getStrategy())
}
/**
* Remove the strategy from passport
*/
unuse() {
passport.unuse(this.name)
this.strategy = null
reload() {
this.client = null
this.openIdAuthSession.clear()
Logger.info('[OidcAuth] Settings reloaded')
}
/**
* Verify callback for OpenID Connect authentication
* 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)
}
}
}
/**
* Handle the OIDC callback - exchange auth code for tokens and verify user.
* Replaces the passport authenticate + verifyCallback flow.
*
* @param {Request} req
* @returns {Promise<{user: import('../models/User'), isMobileCallback: boolean}>} authenticated user and mobile flag
* @throws {AuthError}
*/
async handleCallback(req) {
let sessionData = req.session.oidc
let isMobileCallback = false
if (!sessionData) {
// 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,
sso_redirect_uri: mobileSession.sso_redirect_uri
}
isMobileCallback = true
} else {
throw new AuthError('No OIDC session found', 400)
}
}
const client = this.getClient()
// 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'
})
// Fetch userinfo
const userinfo = await client.userinfo(tokenset.access_token)
// Verify and find/create user
const user = await this.verifyUser(tokenset, userinfo)
// Extract sid from id_token for backchannel logout support
const idTokenClaims = tokenset.claims()
user.openid_session_id = idTokenClaims?.sid ?? null
return { user, isMobileCallback }
}
/**
* Verify user from OIDC token set and userinfo.
* Returns user directly or throws AuthError.
*
* @param {Object} tokenset
* @param {Object} userinfo
* @param {Function} done - Passport callback
* @returns {Promise<import('../models/User')>}
* @throws {AuthError}
*/
async verifyCallback(tokenset, userinfo, done) {
async verifyUser(tokenset, userinfo) {
let isNewUser = false
let user = null
try {
Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
if (!userinfo.sub) {
throw new Error('Invalid userinfo, no sub')
throw new AuthError('Invalid userinfo, no sub', 401)
}
if (!this.validateGroupClaim(userinfo)) {
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
throw new AuthError(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`, 401)
}
// Enforce email_verified check on every login if configured
if (global.ServerSettings.authOpenIDRequireVerifiedEmail && userinfo.email_verified !== true) {
throw new AuthError('Email is not verified', 401)
}
user = await Database.userModel.findUserFromOpenIdUserInfo(userinfo)
if (user?.error) {
throw new Error('Invalid userinfo or already linked')
Logger.warn(`[OidcAuth] User lookup failed: ${user.error}`)
throw new AuthError(user.error, 401)
}
if (!user) {
@ -137,27 +186,31 @@ class OidcAuthStrategy {
isNewUser = true
} else {
Logger.warn(`[User] openid: User not found and auto-register is disabled`)
throw new AuthError('User not found and auto-register is disabled', 401)
}
}
if (!user.isActive) {
throw new Error('User not active or not found')
throw new AuthError('User not active or not found', 401)
}
await this.setUserGroup(user, userinfo)
await this.updateUserPermissions(user, userinfo)
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
// Save the id_token for later (used for logout via DB session)
user.openid_id_token = tokenset.id_token
return done(null, user)
return user
} catch (error) {
Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`)
// Remove new user if an error occurs
if (isNewUser && user) {
await user.destroy()
}
return done(null, null, 'Unauthorized')
if (error instanceof AuthError) {
throw error
}
throw new AuthError(error.message || 'Unauthorized', 401)
}
}
@ -181,6 +234,8 @@ class OidcAuthStrategy {
/**
* Sets the user group based on group claim in userinfo.
* Supports explicit group mapping via authOpenIDGroupMap or legacy direct name match.
*
* @param {import('../models/User')} user
* @param {Object} userinfo
*/
@ -190,17 +245,51 @@ class OidcAuthStrategy {
// No group claim configured, don't set anything
return
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
if (!userinfo[groupClaimName]) throw new AuthError(`Group claim ${groupClaimName} not found in userinfo`, 401)
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
const rawGroups = userinfo[groupClaimName]
// Normalize group claim formats across providers:
// - Array of strings (Keycloak, Auth0): ["admin", "user"]
// - Single string (some providers with one group): "admin"
// - Object with role keys (Zitadel): { "admin": {...}, "user": {...} }
let groups
if (Array.isArray(rawGroups)) {
groups = rawGroups
} else if (typeof rawGroups === 'string') {
groups = [rawGroups]
} else if (typeof rawGroups === 'object' && rawGroups !== null) {
groups = Object.keys(rawGroups)
} else {
throw new AuthError(`Group claim ${groupClaimName} has unsupported format: ${typeof rawGroups}`, 401)
}
const groupsList = groups.map((group) => group.toLowerCase())
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
const groupMap = global.ServerSettings.authOpenIDGroupMap || {}
let userType = null
if (Object.keys(groupMap).length > 0) {
// Explicit group mapping: iterate roles in priority order, check if any mapped group names match
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 {
// Legacy direct name match
userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
}
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
if (userType) {
if (user.type === 'root') {
// Check OpenID Group
if (userType !== 'admin') {
throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`)
Logger.warn(`[OidcAuth] Root user "${user.username}" denied login: IdP group maps to "${userType}", not admin`)
throw new AuthError('Root user cannot be downgraded from admin. Denying login.', 403)
} else {
// If root user is logging in via OpenID, we will not change the type
return
@ -213,7 +302,8 @@ class OidcAuthStrategy {
await user.save()
}
} else {
throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)
Logger.warn(`[OidcAuth] No valid group found in userinfo groups: ${JSON.stringify(userinfo[groupClaimName])}`)
throw new AuthError('No valid group found in userinfo', 401)
}
}
@ -231,7 +321,7 @@ class OidcAuthStrategy {
if (user.type === 'admin' || user.type === 'root') return
const absPermissions = userinfo[absPermissionsClaim]
if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
if (!absPermissions) throw new AuthError(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`, 401)
if (await user.updatePermissionsFromExternalJSON(absPermissions)) {
Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
@ -274,24 +364,23 @@ class OidcAuthStrategy {
*/
isValidRedirectUri(uri) {
// Check if the redirect_uri is in the whitelist
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri)
}
/**
* Get the authorization URL for OpenID Connect
* Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow
* @param {Request} req
* @returns {{ authorizationUrl: string }|{status: number, error: string}}
* @param {boolean} isMobileFlow - whether this is a mobile client flow (determined by caller)
* @param {string|undefined} validatedCallback - pre-validated callback URL for web flow
* @returns {{ authorizationUrl: string, isMobileFlow: boolean }|{status: number, error: string}}
*/
getAuthorizationUrl(req) {
getAuthorizationUrl(req, isMobileFlow, validatedCallback) {
const client = this.getClient()
const strategy = this.getStrategy()
const sessionKey = strategy._key
try {
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge
// Only allow code flow (for mobile clients)
if (req.query.response_type && req.query.response_type !== 'code') {
@ -309,8 +398,6 @@ class OidcAuthStrategy {
let redirectUri
if (isMobileFlow) {
// Mobile required redirect uri
// If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
// where we will handle the redirect to it
if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) {
Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)
return {
@ -318,9 +405,9 @@ class OidcAuthStrategy {
error: 'Invalid redirect_uri'
}
}
// We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API
// for the request to mobile-redirect and as such the session is not shared
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri })
// 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()
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
} else {
@ -335,8 +422,6 @@ class OidcAuthStrategy {
}
}
// Update the strategy's redirect_uri for this request
strategy._params.redirect_uri = redirectUri
Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)
const pkceData = this.generatePkce(req, isMobileFlow)
@ -347,20 +432,37 @@ class OidcAuthStrategy {
}
}
req.session[sessionKey] = {
...req.session[sessionKey],
// Generate nonce to bind id_token to this session (OIDC Core 3.1.2.1)
// Nonce is only used for web flow. Mobile flow relies on PKCE for replay protection,
// and some IdPs don't echo the nonce in the id_token for authorization code flow.
const nonce = isMobileFlow ? undefined : 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,
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,
max_age: strategy._params.max_age,
nonce: nonce,
response_type: 'code',
code_verifier: pkceData.code_verifier, // not null if web flow
mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out
sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback
isMobile: !!isMobileFlow,
sso_redirect_uri: redirectUri, // Save the redirect_uri (for the SSO Provider) for the callback
callbackUrl: !isMobileFlow ? validatedCallback : undefined // web: pre-validated callback URL
}
const authorizationUrl = client.authorizationUrl({
...strategy._params,
redirect_uri: redirectUri,
state: state,
nonce: nonce,
response_type: 'code',
scope: this.getScope(),
code_challenge: pkceData.code_challenge,
@ -396,18 +498,11 @@ class OidcAuthStrategy {
if (authMethod === 'openid') {
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
}
// else for openid-mobile we keep postLogoutRedirectUri on null
// nice would be to redirect to the app here, but for example Authentik does not implement
// the post_logout_redirect_uri parameter at all and for other providers
// we would also need again to implement (and even before get to know somehow for 3rd party apps)
// the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).
// Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution
// (The URL needs to be whitelisted in the config of the SSO/ID provider)
// The client/app can simply append something like
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url
return client.endSessionUrl({
id_token_hint: idToken,
@ -479,7 +574,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)) {
@ -487,18 +582,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')

View file

@ -0,0 +1,348 @@
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, descriptionKey: 'LabelOpenIDClaims' },
{ id: 'advanced', label: 'Advanced', order: 5 }
]
const schema = [
// Endpoints group
{
key: 'authOpenIDIssuerURL',
type: 'text',
label: 'Issuer URL',
group: 'endpoints',
order: 1,
required: true,
validate: 'url'
},
{
key: 'discover',
type: 'action',
label: 'Auto-populate',
group: 'endpoints',
order: 2,
description: 'Fetch endpoints from issuer discovery document',
dependsOn: 'authOpenIDIssuerURL'
},
{
key: 'authOpenIDAuthorizationURL',
type: 'text',
label: 'Authorize URL',
group: 'endpoints',
order: 3,
required: true,
validate: 'url'
},
{
key: 'authOpenIDTokenURL',
type: 'text',
label: 'Token URL',
group: 'endpoints',
order: 4,
required: true,
validate: 'url'
},
{
key: 'authOpenIDUserInfoURL',
type: 'text',
label: 'Userinfo URL',
group: 'endpoints',
order: 5,
required: true,
validate: 'url'
},
{
key: 'authOpenIDJwksURL',
type: 'text',
label: 'JWKS URL',
group: 'endpoints',
order: 6,
required: true,
validate: 'url'
},
{
key: 'authOpenIDLogoutURL',
type: 'text',
label: 'Logout URL',
group: 'endpoints',
order: 7,
validate: 'url'
},
// Credentials group
{
key: 'authOpenIDClientID',
type: 'text',
label: 'Client ID',
group: 'credentials',
order: 1,
required: true
},
{
key: 'authOpenIDClientSecret',
type: 'password',
label: 'Client Secret',
group: 'credentials',
order: 2,
required: true
},
{
key: 'authOpenIDTokenSigningAlgorithm',
type: 'select',
label: 'Signing Algorithm',
group: 'credentials',
order: 3,
required: true,
default: 'RS256',
options: [
{ value: 'RS256', label: 'RS256' },
{ value: 'RS384', label: 'RS384' },
{ value: 'RS512', label: 'RS512' },
{ value: 'ES256', label: 'ES256' },
{ value: 'ES384', label: 'ES384' },
{ value: 'ES512', label: 'ES512' },
{ value: 'PS256', label: 'PS256' },
{ value: 'PS384', label: 'PS384' },
{ value: 'PS512', label: 'PS512' },
{ value: 'EdDSA', label: 'EdDSA' }
]
},
// Behavior group
{
key: 'authOpenIDButtonText',
type: 'text',
label: 'Button Text',
group: 'behavior',
order: 1,
default: 'Login with OpenId'
},
{
key: 'authOpenIDMatchExistingBy',
type: 'select',
label: 'Match Existing Users By',
group: 'behavior',
order: 2,
options: [
{ value: null, label: 'Do not match' },
{ value: 'email', label: 'Match by email' },
{ value: 'username', label: 'Match by username' }
]
},
{
key: 'authOpenIDAutoLaunch',
type: 'boolean',
label: 'Auto Launch',
group: 'behavior',
order: 3,
description: 'Automatically redirect to the OpenID provider on login page'
},
{
key: 'authOpenIDAutoRegister',
type: 'boolean',
label: 'Auto Register',
group: 'behavior',
order: 4,
description: 'Automatically register new users from the OpenID provider'
},
{
key: 'authOpenIDRequireVerifiedEmail',
type: 'boolean',
label: 'Require Verified Email',
group: 'behavior',
order: 5,
description: 'Reject login if email_verified is false in the OIDC userinfo, even for existing users'
},
// Claims group
{
key: 'authOpenIDScopes',
type: 'text',
label: 'Scopes',
group: 'claims',
order: 1,
default: 'openid profile email',
description: 'Space-separated list of OIDC scopes to request'
},
{
key: 'authOpenIDGroupClaim',
type: 'text',
label: 'Group Claim',
group: 'claims',
order: 2,
validate: 'claimName',
descriptionKey: 'LabelOpenIDGroupClaimDescription'
},
{
key: 'authOpenIDGroupMap',
type: 'keyvalue',
label: 'Group Mapping',
group: 'claims',
order: 3,
valueOptions: ['admin', 'user', 'guest'],
description: 'Map OIDC group names to Audiobookshelf roles. If empty, groups are matched by name (admin/user/guest).',
dependsOn: 'authOpenIDGroupClaim'
},
{
key: 'authOpenIDAdvancedPermsClaim',
type: 'text',
label: 'Advanced Permission Claim',
group: 'claims',
order: 4,
validate: 'claimName',
descriptionKey: 'LabelOpenIDAdvancedPermsClaimDescription'
},
// Advanced group
{
key: 'authOpenIDMobileRedirectURIs',
type: 'array',
label: 'Mobile Redirect URIs',
group: 'advanced',
order: 1,
default: ['audiobookshelf://oauth'],
validate: 'uri',
description: 'Allowed redirect URIs for mobile clients.'
},
{
key: 'authOpenIDSubfolderForRedirectURLs',
type: 'select',
label: 'Web Redirect URLs Subfolder',
group: 'advanced',
order: 2,
options: [
{ 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'
}
]
/**
* Get the OIDC settings schema
* @returns {Array} schema field descriptors
*/
function getSchema() {
// Lazily resolve sample permissions to avoid circular dependency at require time
const User = require('../models/User')
return schema.map((field) => {
if (field.key === 'authOpenIDAdvancedPermsClaim') {
return {
...field,
samplePermissions: User.getSampleAbsPermissions()
}
}
return field
})
}
/**
* Get the OIDC settings groups
* @returns {Array} group descriptors
*/
function getGroups() {
return groups
}
/**
* Validate OIDC settings values against the schema
* @param {Object} values - key-value pairs of settings
* @returns {{ valid: boolean, errors?: string[] }}
*/
function validateSettings(values) {
const errors = []
// Reject unknown keys
const knownKeys = new Set(schema.filter((f) => f.type !== 'action').map((f) => f.key))
for (const key of Object.keys(values)) {
if (!knownKeys.has(key)) {
errors.push(`Unknown setting: "${key}"`)
}
}
for (const field of schema) {
if (field.type === 'action') continue
const value = values[field.key]
// Check required fields
if (field.required) {
if (value === undefined || value === null || value === '') {
errors.push(`${field.label} is required`)
continue
}
}
// Skip validation for empty optional fields
if (value === undefined || value === null || value === '') continue
// Type-specific validation
if (field.validate === 'url') {
try {
new URL(value)
} catch {
errors.push(`${field.label}: Invalid URL`)
}
}
if (field.validate === 'uri') {
if (Array.isArray(value)) {
const uriPattern = /^\w+:\/\/[\w.-]+(\/[\w./-]*)?$/i
for (const uri of value) {
if (!uriPattern.test(uri)) {
errors.push(`${field.label}: Invalid URI "${uri}"`)
}
}
}
}
if (field.validate === 'claimName') {
if (typeof value === 'string' && value !== '') {
const claimPattern = /^[a-zA-Z][a-zA-Z0-9_:./-]*$/
if (!claimPattern.test(value)) {
errors.push(`${field.label}: Invalid claim name`)
}
}
}
if (field.type === 'boolean') {
if (typeof value !== 'boolean') {
errors.push(`${field.label}: Expected boolean`)
}
}
if (field.type === 'array') {
if (!Array.isArray(value)) {
errors.push(`${field.label}: Expected array`)
}
}
if (field.type === 'keyvalue') {
if (typeof value !== 'object' || Array.isArray(value) || value === null) {
errors.push(`${field.label}: Expected object`)
} else if (field.valueOptions) {
for (const [k, v] of Object.entries(value)) {
if (!field.valueOptions.includes(v)) {
errors.push(`${field.label}: Invalid value "${v}" for key "${k}". Must be one of: ${field.valueOptions.join(', ')}`)
}
}
}
}
}
if (errors.length > 0) {
return { valid: false, errors }
}
return { valid: true }
}
module.exports = { getSchema, getGroups, validateSettings }

View file

@ -156,9 +156,11 @@ 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) {
async createTokensAndSession(user, req, oidcIdToken = null, oidcSessionId = null) {
const ipAddress = requestIp.getClientIp(req)
const userAgent = req.headers['user-agent']
const accessToken = this.generateTempAccessToken(user)
@ -167,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)
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken, oidcSessionId)
return {
accessToken,
@ -392,6 +394,17 @@ class TokenManager {
return null
}
/**
* Get a session by its refresh token
*
* @param {string} refreshToken
* @returns {Promise<import('../models/Session')|null>}
*/
async getSessionByRefreshToken(refreshToken) {
if (!refreshToken) return null
return await Database.sessionModel.findOne({ where: { refreshToken } })
}
/**
* Invalidate a refresh token - used for logout
*

View file

@ -14,6 +14,7 @@ const { sanitizeFilename } = require('../utils/fileUtils')
const TaskManager = require('../managers/TaskManager')
const adminStats = require('../utils/queries/adminStats')
const OidcSettingsSchema = require('../auth/OidcSettingsSchema')
/**
* @typedef RequestUserObject
@ -625,7 +626,16 @@ class MiscController {
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`)
return res.sendStatus(403)
}
return res.json(Database.serverSettings.authenticationSettings)
const schema = OidcSettingsSchema.getSchema()
const groups = OidcSettingsSchema.getGroups()
const values = Database.serverSettings.openIDSettingsValues
return res.json({
authLoginCustomMessage: Database.serverSettings.authLoginCustomMessage,
authActiveAuthMethods: Database.serverSettings.authActiveAuthMethods,
openIDSettings: { schema, groups, values }
})
}
/**
@ -648,73 +658,83 @@ class MiscController {
let hasUpdates = false
const currentAuthenticationSettings = Database.serverSettings.authenticationSettings
const originalAuthMethods = [...currentAuthenticationSettings.authActiveAuthMethods]
const originalAuthMethods = [...Database.serverSettings.authActiveAuthMethods]
const originalLoginMessage = Database.serverSettings.authLoginCustomMessage
// TODO: Better validation of auth settings once auth settings are separated from server settings
for (const key in currentAuthenticationSettings) {
if (settingsUpdate[key] === undefined) continue
// 1. Update static settings (authLoginCustomMessage, authActiveAuthMethods)
if (settingsUpdate.authLoginCustomMessage !== undefined) {
const newValue = settingsUpdate.authLoginCustomMessage || null
if (newValue !== Database.serverSettings.authLoginCustomMessage) {
Database.serverSettings.authLoginCustomMessage = newValue
hasUpdates = true
}
}
if (key === 'authActiveAuthMethods') {
let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))
if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {
updatedAuthMethods.sort()
currentAuthenticationSettings[key].sort()
if (updatedAuthMethods.join() !== currentAuthenticationSettings[key].join()) {
Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentAuthenticationSettings[key].join()}" to "${updatedAuthMethods.join()}"`)
Database.serverSettings[key] = updatedAuthMethods
hasUpdates = true
}
} else {
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
}
} else if (key === 'authOpenIDMobileRedirectURIs') {
function isValidRedirectURI(uri) {
if (typeof uri !== 'string') return false
const pattern = new RegExp('^\\w+://[\\w\\.-]+(/[\\w\\./-]*)*$', 'i')
return pattern.test(uri)
}
const uris = settingsUpdate[key]
if (!Array.isArray(uris) || (uris.includes('*') && uris.length > 1) || uris.some((uri) => uri !== '*' && !isValidRedirectURI(uri))) {
Logger.warn(`[MiscController] Invalid value for authOpenIDMobileRedirectURIs`)
continue
}
// Update the URIs
if (Database.serverSettings[key].some((uri) => !uris.includes(uri)) || uris.some((uri) => !Database.serverSettings[key].includes(uri))) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${Database.serverSettings[key]}" to "${uris}"`)
Database.serverSettings[key] = uris
if (settingsUpdate.authActiveAuthMethods !== undefined) {
let updatedAuthMethods = settingsUpdate.authActiveAuthMethods?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))
if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {
updatedAuthMethods.sort()
const currentSorted = [...Database.serverSettings.authActiveAuthMethods].sort()
if (updatedAuthMethods.join() !== currentSorted.join()) {
Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentSorted.join()}" to "${updatedAuthMethods.join()}"`)
Database.serverSettings.authActiveAuthMethods = updatedAuthMethods
hasUpdates = true
}
} else {
const updatedValueType = typeof settingsUpdate[key]
if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {
if (updatedValueType !== 'boolean') {
Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`)
continue
}
} else if (settingsUpdate[key] !== null && updatedValueType !== 'string') {
Logger.warn(`[MiscController] Invalid value for ${key}. Expected string or null`)
continue
}
let updatedValue = settingsUpdate[key]
if (updatedValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') updatedValue = null
let currentValue = currentAuthenticationSettings[key]
if (currentValue === '' && key != 'authOpenIDSubfolderForRedirectURLs') currentValue = null
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
}
}
if (updatedValue !== currentValue) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
Database.serverSettings[key] = updatedValue
// Reject enabling openid without valid OIDC configuration
if (Database.serverSettings.authActiveAuthMethods.includes('openid') && !originalAuthMethods.includes('openid')) {
if (!Database.serverSettings.isOpenIDAuthSettingsValid && !settingsUpdate.openIDSettings) {
Logger.warn(`[MiscController] Cannot enable openid auth without valid OIDC configuration`)
Database.serverSettings.authActiveAuthMethods = originalAuthMethods
return res.status(400).json({ error: 'Cannot enable OpenID auth without valid OIDC configuration. Configure OIDC settings first.' })
}
}
// 2. Update OIDC settings via schema validation
if (settingsUpdate.openIDSettings && isObject(settingsUpdate.openIDSettings)) {
const oidcValues = settingsUpdate.openIDSettings
const validation = OidcSettingsSchema.validateSettings(oidcValues)
if (!validation.valid) {
// Rollback any in-memory changes made before validation
Database.serverSettings.authActiveAuthMethods = originalAuthMethods
Database.serverSettings.authLoginCustomMessage = originalLoginMessage
return res.status(400).json({ error: 'Invalid OIDC settings', details: validation.errors })
}
// Apply validated OIDC settings
const currentValues = Database.serverSettings.openIDSettingsValues
for (const key of Object.keys(currentValues)) {
if (oidcValues[key] === undefined) continue
const newValue = oidcValues[key]
const currentValue = currentValues[key]
// Deep comparison for objects/arrays
const newStr = JSON.stringify(newValue)
const curStr = JSON.stringify(currentValue)
if (newStr !== curStr) {
Logger.debug(`[MiscController] Updating OIDC setting "${key}"`)
Database.serverSettings[key] = newValue
hasUpdates = true
}
}
// Live reload OIDC strategy if settings changed (only when openid was already active,
// since the use/unuse block below handles the case where openid is being newly enabled)
if (hasUpdates && Database.serverSettings.authActiveAuthMethods.includes('openid') && originalAuthMethods.includes('openid')) {
this.auth.oidcAuthStrategy.reload()
}
}
if (hasUpdates) {
await Database.updateServerSettings()
// Use/unuse auth methods
// Use/unuse auth methods (this calls reload() for newly enabled/disabled openid)
Database.serverSettings.supportedAuthMethods.forEach((authMethod) => {
if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
// Auth method has been removed
@ -734,6 +754,56 @@ class MiscController {
})
}
/**
* POST: api/auth-settings/openid/discover
* Discover OpenID Connect configuration from an issuer URL
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async discoverOpenIDConfig(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to discover OIDC config`)
return res.sendStatus(403)
}
const { issuerUrl } = req.body
if (!issuerUrl) {
return res.status(400).json({ error: 'issuerUrl required' })
}
try {
const config = await this.auth.oidcAuthStrategy.getIssuerConfig(issuerUrl)
if (config.error) {
return res.status(config.status).json({ error: config.error })
}
// Map discovery to setting values
const values = {
authOpenIDIssuerURL: config.issuer,
authOpenIDAuthorizationURL: config.authorization_endpoint,
authOpenIDTokenURL: config.token_endpoint,
authOpenIDUserInfoURL: config.userinfo_endpoint,
authOpenIDJwksURL: config.jwks_uri,
authOpenIDLogoutURL: config.end_session_endpoint || null,
authOpenIDTokenSigningAlgorithm: config.id_token_signing_alg_values_supported?.[0] || 'RS256'
}
const schemaOverrides = {}
if (config.id_token_signing_alg_values_supported?.length) {
schemaOverrides.authOpenIDTokenSigningAlgorithm = {
type: 'select',
options: config.id_token_signing_alg_values_supported.map((alg) => ({ value: alg, label: alg }))
}
}
res.json({ values, schemaOverrides })
} catch (error) {
Logger.error(`[MiscController] Error discovering OIDC config: ${error.message}`)
return res.status(500).json({ error: 'Failed to discover OIDC configuration' })
}
}
/**
* GET: /api/stats/year/:year
*

View file

@ -0,0 +1,143 @@
/**
* @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.33.0'
const migrationName = `${migrationVersion}-oidc-scopes-and-group-map`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This migration adds oidcIdToken column to sessions table and computes
* authOpenIDScopes / authOpenIDGroupMap from existing OIDC config.
*
* @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}`)
// 2a: Add oidcIdToken column to sessions table
if (await queryInterface.tableExists('sessions')) {
const tableDescription = await queryInterface.describeTable('sessions')
if (!tableDescription.oidcIdToken) {
logger.info(`${loggerPrefix} Adding oidcIdToken column to sessions table`)
await queryInterface.addColumn('sessions', 'oidcIdToken', {
type: queryInterface.sequelize.Sequelize.DataTypes.TEXT,
allowNull: true
})
logger.info(`${loggerPrefix} Added oidcIdToken column to sessions table`)
} else {
logger.info(`${loggerPrefix} oidcIdToken column already exists in sessions table`)
}
} else {
logger.info(`${loggerPrefix} sessions table does not exist`)
}
// 2b: Compute authOpenIDScopes from existing config
// NOTE: This preserves backward compatibility by appending claim names as scopes.
// In OIDC, claim names and scope names are not always the same (e.g., a "groups" claim
// might be included via the "openid" scope). Users may need to adjust scopes after upgrade.
const serverSettings = await getServerSettings(queryInterface, logger)
if (serverSettings.authOpenIDScopes === undefined) {
let scope = 'openid profile email'
if (serverSettings.authOpenIDGroupClaim) {
scope += ' ' + serverSettings.authOpenIDGroupClaim
}
if (serverSettings.authOpenIDAdvancedPermsClaim) {
scope += ' ' + serverSettings.authOpenIDAdvancedPermsClaim
}
serverSettings.authOpenIDScopes = scope.trim()
logger.info(`${loggerPrefix} Computed authOpenIDScopes: "${serverSettings.authOpenIDScopes}"`)
} else {
logger.info(`${loggerPrefix} authOpenIDScopes already exists in server settings`)
}
if (serverSettings.authOpenIDGroupMap === undefined) {
serverSettings.authOpenIDGroupMap = {}
logger.info(`${loggerPrefix} Initialized authOpenIDGroupMap`)
} else {
logger.info(`${loggerPrefix} authOpenIDGroupMap already exists in server settings`)
}
await updateServerSettings(queryInterface, logger, serverSettings)
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This migration removes oidcIdToken column from sessions table and
* removes authOpenIDScopes / authOpenIDGroupMap 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 oidcIdToken column from sessions table
if (await queryInterface.tableExists('sessions')) {
const tableDescription = await queryInterface.describeTable('sessions')
if (tableDescription.oidcIdToken) {
logger.info(`${loggerPrefix} Removing oidcIdToken column from sessions table`)
await queryInterface.removeColumn('sessions', 'oidcIdToken')
logger.info(`${loggerPrefix} Removed oidcIdToken column from sessions table`)
} else {
logger.info(`${loggerPrefix} oidcIdToken column does not exist in sessions table`)
}
} else {
logger.info(`${loggerPrefix} sessions table does not exist`)
}
// Remove authOpenIDScopes and authOpenIDGroupMap from server settings
const serverSettings = await getServerSettings(queryInterface, logger)
let changed = false
if (serverSettings.authOpenIDScopes !== undefined) {
delete serverSettings.authOpenIDScopes
changed = true
logger.info(`${loggerPrefix} Removed authOpenIDScopes from server settings`)
}
if (serverSettings.authOpenIDGroupMap !== undefined) {
delete serverSettings.authOpenIDGroupMap
changed = true
logger.info(`${loggerPrefix} Removed authOpenIDGroupMap from server settings`)
}
if (changed) {
await updateServerSettings(queryInterface, logger, serverSettings)
}
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 }

View 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 }

View file

@ -18,6 +18,10 @@ class Session extends Model {
this.userId
/** @type {Date} */
this.expiresAt
/** @type {string} */
this.oidcIdToken
/** @type {string} */
this.oidcSessionId
// Expanded properties
@ -25,8 +29,8 @@ class Session extends Model {
this.user
}
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) {
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt })
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
}
@ -66,7 +70,9 @@ class Session extends Model {
expiresAt: {
type: DataTypes.DATE,
allowNull: false
}
},
oidcIdToken: DataTypes.TEXT,
oidcSessionId: DataTypes.STRING
},
{
sequelize,

View file

@ -82,6 +82,10 @@ class ServerSettings {
this.authOpenIDGroupClaim = ''
this.authOpenIDAdvancedPermsClaim = ''
this.authOpenIDSubfolderForRedirectURLs = undefined
this.authOpenIDScopes = 'openid profile email'
this.authOpenIDGroupMap = {}
this.authOpenIDRequireVerifiedEmail = false
this.authOpenIDBackchannelLogoutEnabled = false
if (settings) {
this.construct(settings)
@ -146,6 +150,10 @@ class ServerSettings {
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
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']
@ -255,7 +263,11 @@ class ServerSettings {
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
authOpenIDScopes: this.authOpenIDScopes,
authOpenIDGroupMap: this.authOpenIDGroupMap,
authOpenIDRequireVerifiedEmail: this.authOpenIDRequireVerifiedEmail,
authOpenIDBackchannelLogoutEnabled: this.authOpenIDBackchannelLogoutEnabled
}
}
@ -267,6 +279,9 @@ class ServerSettings {
delete json.authOpenIDMobileRedirectURIs
delete json.authOpenIDGroupClaim
delete json.authOpenIDAdvancedPermsClaim
delete json.authOpenIDScopes
delete json.authOpenIDGroupMap
delete json.authOpenIDRequireVerifiedEmail
return json
}
@ -281,29 +296,42 @@ class ServerSettings {
return this.authOpenIDIssuerURL && this.authOpenIDAuthorizationURL && this.authOpenIDTokenURL && this.authOpenIDUserInfoURL && this.authOpenIDJwksURL && this.authOpenIDClientID && this.authOpenIDClientSecret && this.authOpenIDTokenSigningAlgorithm
}
get authenticationSettings() {
/**
* All OIDC-related setting keys (values only, for admin API)
*/
get openIDSettingsValues() {
return {
authLoginCustomMessage: this.authLoginCustomMessage,
authActiveAuthMethods: this.authActiveAuthMethods,
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
authOpenIDTokenURL: this.authOpenIDTokenURL,
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
authOpenIDJwksURL: this.authOpenIDJwksURL,
authOpenIDLogoutURL: this.authOpenIDLogoutURL,
authOpenIDClientID: this.authOpenIDClientID, // Do not return to client
authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client
authOpenIDClientID: this.authOpenIDClientID,
authOpenIDClientSecret: this.authOpenIDClientSecret,
authOpenIDTokenSigningAlgorithm: this.authOpenIDTokenSigningAlgorithm,
authOpenIDButtonText: this.authOpenIDButtonText,
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs,
authOpenIDGroupClaim: this.authOpenIDGroupClaim,
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim,
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
authOpenIDScopes: this.authOpenIDScopes,
authOpenIDGroupMap: this.authOpenIDGroupMap,
authOpenIDRequireVerifiedEmail: this.authOpenIDRequireVerifiedEmail,
authOpenIDBackchannelLogoutEnabled: this.authOpenIDBackchannelLogoutEnabled
}
}
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
get authenticationSettings() {
return {
authLoginCustomMessage: this.authLoginCustomMessage,
authActiveAuthMethods: this.authActiveAuthMethods,
openIDSettings: {
values: this.openIDSettingsValues
}
}
}

View file

@ -352,6 +352,7 @@ class ApiRouter {
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
this.router.post('/auth-settings/openid/discover', MiscController.discoverOpenIDConfig.bind(this))
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
this.router.get('/logger-data', MiscController.getLoggerData.bind(this))
}

View file

@ -0,0 +1,24 @@
const { expect } = require('chai')
const AuthError = require('../../../server/auth/AuthError')
describe('AuthError', function () {
it('should create error with default statusCode 500', function () {
const error = new AuthError('Something went wrong')
expect(error.message).to.equal('Something went wrong')
expect(error.statusCode).to.equal(500)
expect(error.name).to.equal('AuthError')
expect(error).to.be.instanceOf(Error)
})
it('should create error with custom statusCode', function () {
const error = new AuthError('Unauthorized', 401)
expect(error.message).to.equal('Unauthorized')
expect(error.statusCode).to.equal(401)
})
it('should have a stack trace', function () {
const error = new AuthError('test')
expect(error.stack).to.be.a('string')
expect(error.stack).to.include('AuthError')
})
})

View 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
})
})

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,173 @@
const { expect } = require('chai')
const { validateSettings } = require('../../../server/auth/OidcSettingsSchema')
describe('OidcSettingsSchema - validateSettings', function () {
const validSettings = {
authOpenIDIssuerURL: 'https://auth.example.com',
authOpenIDAuthorizationURL: 'https://auth.example.com/authorize',
authOpenIDTokenURL: 'https://auth.example.com/token',
authOpenIDUserInfoURL: 'https://auth.example.com/userinfo',
authOpenIDJwksURL: 'https://auth.example.com/jwks',
authOpenIDClientID: 'my-client-id',
authOpenIDClientSecret: 'my-client-secret',
authOpenIDTokenSigningAlgorithm: 'RS256'
}
it('should pass with valid required settings', function () {
const result = validateSettings(validSettings)
expect(result.valid).to.be.true
})
it('should fail when required fields are missing', function () {
const result = validateSettings({})
expect(result.valid).to.be.false
expect(result.errors).to.include('Issuer URL is required')
expect(result.errors).to.include('Client ID is required')
expect(result.errors).to.include('Client Secret is required')
})
it('should fail with invalid URL', function () {
const result = validateSettings({
...validSettings,
authOpenIDIssuerURL: 'not-a-url'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Issuer URL: Invalid URL')
})
it('should pass with valid optional fields', function () {
const result = validateSettings({
...validSettings,
authOpenIDLogoutURL: 'https://auth.example.com/logout',
authOpenIDButtonText: 'Login with SSO',
authOpenIDAutoLaunch: false,
authOpenIDAutoRegister: true,
authOpenIDScopes: 'openid profile email groups',
authOpenIDGroupClaim: 'groups'
})
expect(result.valid).to.be.true
})
it('should fail with invalid boolean type', function () {
const result = validateSettings({
...validSettings,
authOpenIDAutoLaunch: 'yes'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Auto Launch: Expected boolean')
})
it('should fail with invalid claim name', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupClaim: '123invalid'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Group Claim: Invalid claim name')
})
it('should pass with valid claim name', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupClaim: 'my-groups_claim'
})
expect(result.valid).to.be.true
})
it('should pass with URN-style claim name (e.g. ZITADEL)', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupClaim: 'urn:zitadel:iam:org:project:roles'
})
expect(result.valid).to.be.true
})
it('should fail with invalid group map values', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupMap: { 'my-group': 'superadmin' }
})
expect(result.valid).to.be.false
expect(result.errors[0]).to.include('Invalid value "superadmin"')
})
it('should pass with valid group map', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupMap: { 'oidc-admins': 'admin', 'oidc-users': 'user', 'oidc-guests': 'guest' }
})
expect(result.valid).to.be.true
})
it('should fail with non-object group map', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupMap: 'not-an-object'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Group Mapping: Expected object')
})
it('should fail with invalid mobile redirect URIs', function () {
const result = validateSettings({
...validSettings,
authOpenIDMobileRedirectURIs: 'not-an-array'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Mobile Redirect URIs: Expected array')
})
it('should pass with valid redirect URI', function () {
const result = validateSettings({
...validSettings,
authOpenIDMobileRedirectURIs: ['audiobookshelf://oauth']
})
expect(result.valid).to.be.true
})
it('should fail with wildcard URI', function () {
const result = validateSettings({
...validSettings,
authOpenIDMobileRedirectURIs: ['*']
})
expect(result.valid).to.be.false
expect(result.errors[0]).to.include('Invalid URI')
})
it('should not hang on pathological URI input', function () {
this.timeout(1000)
const result = validateSettings({
...validSettings,
authOpenIDMobileRedirectURIs: ['a://-/' + '/'.repeat(100) + '!']
})
expect(result.valid).to.be.false
expect(result.errors[0]).to.include('Invalid URI')
})
it('should accept URI with path segments', function () {
const result = validateSettings({
...validSettings,
authOpenIDMobileRedirectURIs: ['https://example.com/path/to/callback']
})
expect(result.valid).to.be.true
})
it('should reject unknown keys', function () {
const result = validateSettings({
...validSettings,
unknownSetting: 'value'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Unknown setting: "unknownSetting"')
})
it('should skip validation for empty optional fields', function () {
const result = validateSettings({
...validSettings,
authOpenIDLogoutURL: '',
authOpenIDGroupClaim: '',
authOpenIDGroupMap: {}
})
expect(result.valid).to.be.true
})
})