mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-04-18 13:09:43 +00:00
Add OIDC Back-Channel Logout support
Implement OIDC Back-Channel Logout 1.0 (RFC). When enabled, the IdP can POST a signed logout_token JWT to invalidate user sessions server-side. - Add BackchannelLogoutHandler: JWT verification via jose, jti replay protection with bounded cache, session destruction by sub or sid - Add oidcSessionId column to sessions table with index for fast lookups - Add backchannel logout route (POST /auth/openid/backchannel-logout) - Notify connected clients via socket to redirect to login page - Add authOpenIDBackchannelLogoutEnabled toggle in schema-driven settings UI - Migration v2.34.0 adds oidcSessionId column and index - Polish settings UI: auto-populate loading state, subfolder dropdown options, KeyValueEditor fixes, localized descriptions via descriptionKey, duplicate key detection, success/error toasts - Localize backchannel logout toast (ToastSessionEndedByProvider) - OidcAuthStrategy tests now use real class via require-cache stubbing
This commit is contained in:
parent
33bee70a12
commit
073eff74ef
16 changed files with 886 additions and 104 deletions
|
|
@ -3,7 +3,7 @@
|
|||
<p v-if="label" class="text-sm font-semibold px-1 mb-1" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<div v-for="(entry, index) in entries" :key="index" class="flex items-center gap-2 mb-2">
|
||||
<div class="flex-grow">
|
||||
<input type="text" :value="entry.key" :disabled="disabled" class="w-full rounded-sm bg-primary border border-gray-600 text-sm px-3 py-2" placeholder="Group name" @input="updateKey(index, $event.target.value)" />
|
||||
<input type="text" :value="entry.key" :disabled="disabled" class="w-full rounded-sm bg-primary text-sm px-3 py-2" :class="isDuplicateKey(entry.key, index) ? 'border border-warning' : 'border border-gray-600'" :title="isDuplicateKey(entry.key, index) ? 'Duplicate group name' : ''" placeholder="Group name" @input="updateKey(index, $event.target.value)" />
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<select :value="entry.value" :disabled="disabled" class="w-full rounded-sm bg-primary border border-gray-600 text-sm px-2 py-2" @change="updateValue(index, $event.target.value)">
|
||||
|
|
@ -14,7 +14,8 @@
|
|||
<span class="material-symbols text-xl">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" :disabled="disabled" class="flex items-center text-sm text-gray-300 hover:text-white" @click="addEntry">
|
||||
<p v-if="hasDuplicates" class="text-warning text-xs px-1 mb-1">Duplicate group names — only the last entry will be kept</p>
|
||||
<button type="button" :disabled="disabled" class="flex items-center text-sm text-gray-300 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed" @click="addEntry">
|
||||
<span class="material-symbols text-lg mr-1">add</span>
|
||||
<span>Add mapping</span>
|
||||
</button>
|
||||
|
|
@ -40,15 +41,33 @@ export default {
|
|||
entries: Object.entries(this.value || {}).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasDuplicates() {
|
||||
const keys = this.entries.map((e) => e.key).filter((k) => k)
|
||||
return new Set(keys).size !== keys.length
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
handler(newVal) {
|
||||
this.entries = Object.entries(newVal || {}).map(([key, value]) => ({ key, value }))
|
||||
// Only rebuild entries if the prop differs from what local state would emit.
|
||||
// This prevents re-rendering (and closing dropdowns) when our own emit echoes back.
|
||||
const currentOutput = {}
|
||||
for (const entry of this.entries) {
|
||||
if (entry.key) currentOutput[entry.key] = entry.value
|
||||
}
|
||||
if (JSON.stringify(newVal || {}) !== JSON.stringify(currentOutput)) {
|
||||
this.entries = Object.entries(newVal || {}).map(([key, value]) => ({ key, value }))
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isDuplicateKey(key, index) {
|
||||
if (!key) return false
|
||||
return this.entries.some((e, i) => i !== index && e.key === key)
|
||||
},
|
||||
emitUpdate() {
|
||||
const obj = {}
|
||||
for (const entry of this.entries) {
|
||||
|
|
@ -72,7 +91,6 @@ export default {
|
|||
},
|
||||
addEntry() {
|
||||
this.entries.push({ key: '', value: this.valueOptions[0] || '' })
|
||||
this.emitUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,25 @@
|
|||
<div class="w-full">
|
||||
<div v-for="group in sortedGroups" :key="group.id" class="mb-4">
|
||||
<p class="text-sm font-semibold text-gray-200 uppercase tracking-wide mb-2 px-1">{{ group.label }}</p>
|
||||
<p v-if="getGroupDescription(group)" class="text-sm text-gray-300 mb-2 px-1">{{ getGroupDescription(group) }}</p>
|
||||
<div class="flex flex-wrap">
|
||||
<template v-for="field in fieldsForGroup(group.id)">
|
||||
<!-- Action button (e.g., Auto-populate) -->
|
||||
<div v-if="field.type === 'action'" :key="field.key" class="w-36 mx-1 mt-[1.375rem] mb-2">
|
||||
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" :disabled="isFieldDisabled(field)" @click.stop="$emit('action', field.key)">
|
||||
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" :disabled="isFieldDisabled(field)" :loading="loadingActions.includes(field.key)" @click.stop="$emit('action', field.key)">
|
||||
<span class="material-symbols text-base">auto_fix_high</span>
|
||||
<span class="whitespace-nowrap break-keep pl-1">{{ field.label }}</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
|
||||
<!-- Text input -->
|
||||
<ui-text-input-with-label v-else-if="field.type === 'text'" :key="field.key" :value="values[field.key]" :disabled="disabled || isFieldDisabled(field)" :label="field.label" class="mb-2" @input="onFieldChange(field.key, $event)" />
|
||||
<div v-else-if="field.type === 'text'" :key="field.key" class="w-full mb-2">
|
||||
<ui-text-input-with-label :value="values[field.key]" :disabled="disabled || isFieldDisabled(field)" :label="field.label" @input="onFieldChange(field.key, $event)" />
|
||||
<div v-if="getFieldHtmlDescription(field)" class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300">
|
||||
<p v-html="getFieldHtmlDescription(field)"></p>
|
||||
<pre v-if="field.samplePermissions" class="text-pre-wrap mt-2">{{ field.samplePermissions }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password input -->
|
||||
<ui-text-input-with-label v-else-if="field.type === 'password'" :key="field.key" :value="values[field.key]" :disabled="disabled || isFieldDisabled(field)" :label="field.label" type="password" class="mb-2" @input="onFieldChange(field.key, $event)" />
|
||||
|
|
@ -22,7 +29,7 @@
|
|||
<div v-else-if="field.type === 'boolean'" :key="field.key" class="flex items-center py-4 px-1 w-full">
|
||||
<ui-toggle-switch :value="!!values[field.key]" :disabled="disabled || isFieldDisabled(field)" @input="onFieldChange(field.key, $event)" />
|
||||
<p class="pl-4 whitespace-nowrap">{{ field.label }}</p>
|
||||
<p v-if="field.description" class="pl-4 text-sm text-gray-300">{{ field.description }}</p>
|
||||
<p v-if="field.description" class="pl-4 text-sm text-gray-300">{{ resolveDescription(field.description) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Select dropdown -->
|
||||
|
|
@ -69,7 +76,11 @@ export default {
|
|||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
loadingActions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
sortedGroups() {
|
||||
|
|
@ -94,6 +105,19 @@ export default {
|
|||
value: opt.value
|
||||
}))
|
||||
},
|
||||
getGroupDescription(group) {
|
||||
if (group.descriptionKey) return this.$strings[group.descriptionKey] || ''
|
||||
return group.description || ''
|
||||
},
|
||||
getFieldHtmlDescription(field) {
|
||||
if (field.descriptionKey) return this.$strings[field.descriptionKey] || ''
|
||||
return field.description || ''
|
||||
},
|
||||
resolveDescription(desc) {
|
||||
if (!desc || !desc.includes('{baseURL}')) return desc
|
||||
const baseURL = window.location.origin + this.$config.routerBasePath
|
||||
return desc.replace('{baseURL}', baseURL)
|
||||
},
|
||||
onFieldChange(key, value) {
|
||||
this.$emit('update', { key, value })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -364,6 +364,14 @@ export default {
|
|||
adminMessageEvt(message) {
|
||||
this.$toast.info(message)
|
||||
},
|
||||
backchannelLogout() {
|
||||
console.log('[SOCKET] Backchannel logout received from identity provider')
|
||||
this.$toast.warning(this.$strings.ToastSessionEndedByProvider, { timeout: 5000 })
|
||||
// Use a timeout so the toast is visible before redirect
|
||||
setTimeout(() => {
|
||||
window.location.replace(`${this.$config.routerBasePath}/login`)
|
||||
}, 1000)
|
||||
},
|
||||
ereaderDevicesUpdated(data) {
|
||||
if (!data?.ereaderDevices) return
|
||||
|
||||
|
|
@ -474,6 +482,9 @@ export default {
|
|||
|
||||
this.socket.on('admin_message', this.adminMessageEvt)
|
||||
|
||||
// OIDC Back-Channel Logout
|
||||
this.socket.on('backchannel_logout', this.backchannelLogout)
|
||||
|
||||
// Custom metadata provider Listeners
|
||||
this.socket.on('custom_metadata_provider_added', this.customMetadataProviderAdded)
|
||||
this.socket.on('custom_metadata_provider_removed', this.customMetadataProviderRemoved)
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
|
||||
<transition name="slide">
|
||||
<div v-if="enableOpenIDAuth" class="pt-4">
|
||||
<app-oidc-settings :schema="openIDSchema" :groups="openIDGroups" :values="openIDValues" :schema-overrides="openIDSchemaOverrides" :disabled="savingSettings" @update="onOidcSettingChange" @action="onOidcAction" />
|
||||
<app-oidc-settings :schema="openIDSchema" :groups="openIDGroups" :values="openIDValues" :schema-overrides="openIDSchemaOverrides" :disabled="savingSettings" :loading-actions="discovering ? ['discover'] : []" @update="onOidcSettingChange" @action="onOidcAction" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
|
@ -69,6 +69,7 @@ export default {
|
|||
enableOpenIDAuth: false,
|
||||
showCustomLoginMessage: false,
|
||||
savingSettings: false,
|
||||
discovering: false,
|
||||
openIDSchemaOverrides: {},
|
||||
newAuthSettings: {},
|
||||
openIDValues: {}
|
||||
|
|
@ -110,6 +111,7 @@ export default {
|
|||
this.$set(this.openIDValues, 'authOpenIDIssuerURL', issuerUrl)
|
||||
}
|
||||
|
||||
this.discovering = true
|
||||
try {
|
||||
const data = await this.$axios.$post('/api/auth-settings/openid/discover', { issuerUrl })
|
||||
|
||||
|
|
@ -122,14 +124,18 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
// Apply schema overrides (e.g., supported signing algorithms)
|
||||
// Merge schema overrides (e.g., supported signing algorithms) with existing ones
|
||||
if (data.schemaOverrides) {
|
||||
this.openIDSchemaOverrides = data.schemaOverrides
|
||||
this.openIDSchemaOverrides = { ...this.openIDSchemaOverrides, ...data.schemaOverrides }
|
||||
}
|
||||
|
||||
this.$toast.success('Provider endpoints auto-populated')
|
||||
} catch (error) {
|
||||
console.error('Failed to discover OIDC config', error)
|
||||
const errorMsg = error.response?.data?.error || error.response?.data || 'Unknown error'
|
||||
this.$toast.error(errorMsg)
|
||||
} finally {
|
||||
this.discovering = false
|
||||
}
|
||||
},
|
||||
async saveSettings() {
|
||||
|
|
@ -185,6 +191,16 @@ export default {
|
|||
authOpenIDSubfolderForRedirectURLs: serverValues.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : serverValues.authOpenIDSubfolderForRedirectURLs
|
||||
}
|
||||
|
||||
// Build subfolder dropdown options from routerBasePath
|
||||
const basePath = this.$config.routerBasePath
|
||||
const subfolderOptions = [{ value: '', label: 'None' }]
|
||||
if (basePath && basePath !== '/') {
|
||||
subfolderOptions.push({ value: basePath, label: basePath })
|
||||
}
|
||||
this.openIDSchemaOverrides = {
|
||||
authOpenIDSubfolderForRedirectURLs: { options: subfolderOptions }
|
||||
}
|
||||
|
||||
this.enableLocalAuth = this.authMethods.includes('local')
|
||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||
this.showCustomLoginMessage = !!this.authSettings.authLoginCustomMessage
|
||||
|
|
|
|||
|
|
@ -1131,6 +1131,7 @@
|
|||
"ToastSeriesUpdateFailed": "Series update failed",
|
||||
"ToastSeriesUpdateSuccess": "Series update success",
|
||||
"ToastServerSettingsUpdateSuccess": "Server settings updated",
|
||||
"ToastSessionEndedByProvider": "Session ended by identity provider",
|
||||
"ToastSessionCloseFailed": "Failed to close session",
|
||||
"ToastSessionDeleteFailed": "Failed to delete session",
|
||||
"ToastSessionDeleteSuccess": "Session deleted",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue