audiobookshelf/client/pages/config/authentication.vue
Denis Arnst 073eff74ef
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
2026-02-05 17:55:10 +01:00

224 lines
7.8 KiB
Vue

<template>
<div id="authentication-settings">
<app-settings-content :header-text="$strings.HeaderAuthentication">
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
<div class="flex items-center">
<ui-checkbox v-model="showCustomLoginMessage" checkbox-bg="bg" />
<p class="text-lg pl-4">{{ $strings.HeaderCustomMessageOnLogin }}</p>
</div>
<transition name="slide">
<div v-if="showCustomLoginMessage" class="w-full pt-4">
<ui-rich-text-editor v-model="newAuthSettings.authLoginCustomMessage" />
</div>
</transition>
</div>
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
<div class="flex items-center">
<ui-checkbox v-model="enableLocalAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">{{ $strings.HeaderPasswordAuthentication }}</p>
</div>
</div>
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
<div class="flex items-center">
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">{{ $strings.HeaderOpenIDConnectAuthentication }}</p>
<ui-tooltip :text="$strings.LabelClickForMoreInfo" class="inline-flex ml-2">
<a href="https://www.audiobookshelf.org/guides/oidc_authentication" target="_blank" class="inline-flex">
<span class="material-symbols text-xl w-5 text-gray-200">help_outline</span>
</a>
</ui-tooltip>
</div>
<transition name="slide">
<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-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>
</div>
</template>
<script>
export default {
async asyncData({ store, redirect, app }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
return
}
const authSettings = await app.$axios.$get('/api/auth-settings').catch((error) => {
console.error('Failed', error)
return null
})
if (!authSettings) {
redirect('/config')
return
}
return {
authSettings
}
},
data() {
return {
enableLocalAuth: false,
enableOpenIDAuth: false,
showCustomLoginMessage: false,
savingSettings: false,
discovering: false,
openIDSchemaOverrides: {},
newAuthSettings: {},
openIDValues: {}
}
},
computed: {
authMethods() {
return this.authSettings.authActiveAuthMethods || []
},
openIDSchema() {
return this.authSettings.openIDSettings?.schema || []
},
openIDGroups() {
return this.authSettings.openIDSettings?.groups || []
}
},
methods: {
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
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.$set(this.openIDValues, 'authOpenIDIssuerURL', issuerUrl)
}
this.discovering = true
try {
const data = await this.$axios.$post('/api/auth-settings/openid/discover', { issuerUrl })
// 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)
}
}
}
// Merge schema overrides (e.g., supported signing algorithms) with existing ones
if (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() {
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
this.$toast.error('Must have at least one authentication method enabled')
return
}
if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) {
this.newAuthSettings.authLoginCustomMessage = null
}
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
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
}
},
init() {
this.newAuthSettings = {
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
}
},
mounted() {
this.init()
}
}
</script>
<style>
#authentication-settings code {
font-size: 0.8rem;
border-radius: 6px;
background-color: rgb(82, 82, 82);
color: white;
padding: 2px 4px;
white-space: nowrap;
}
</style>