mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-06 07:59:43 +00:00
Revamp OIDC auth: remove Passport wrapper, add schema-driven settings UI
- Remove Passport.js wrapper from OIDC auth, use openid-client directly - Add schema-driven OIDC settings UI (OidcSettingsSchema.js drives form rendering) - Add group mapping with KeyValueEditor (explicit mapping or legacy direct name match) - Add scopes configuration (authOpenIDScopes) - Add verified email enforcement option (authOpenIDRequireVerifiedEmail) - Fix group claim validation rejecting URN-style claims (#4744) - Add auto-discover endpoint for OIDC provider configuration - Store oidcIdToken in sessions table instead of cookie - Add AuthError class for structured error handling in auth flows - Migration v2.33.0 adds oidcIdToken column and new settings fields
This commit is contained in:
parent
fe13456a2b
commit
33bee70a12
16 changed files with 1554 additions and 571 deletions
79
client/components/app/KeyValueEditor.vue
Normal file
79
client/components/app/KeyValueEditor.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<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 border border-gray-600 text-sm px-3 py-2" 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>
|
||||||
|
<button type="button" :disabled="disabled" class="flex items-center text-sm text-gray-300 hover:text-white" @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 }))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
value: {
|
||||||
|
handler(newVal) {
|
||||||
|
this.entries = Object.entries(newVal || {}).map(([key, value]) => ({ key, value }))
|
||||||
|
},
|
||||||
|
deep: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
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] || '' })
|
||||||
|
this.emitUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
102
client/components/app/OidcSettings.vue
Normal file
102
client/components/app/OidcSettings.vue
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
<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>
|
||||||
|
<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)">
|
||||||
|
<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)" />
|
||||||
|
|
||||||
|
<!-- 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">{{ 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
|
||||||
|
},
|
||||||
|
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
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
onFieldChange(key, value) {
|
||||||
|
this.$emit('update', { key, value })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -31,99 +31,12 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4">
|
<div v-if="enableOpenIDAuth" class="pt-4">
|
||||||
<div class="w-full flex items-center mb-2">
|
<app-oidc-settings :schema="openIDSchema" :groups="openIDGroups" :values="openIDValues" :schema-overrides="openIDSchemaOverrides" :disabled="savingSettings" @update="onOidcSettingChange" @action="onOidcAction" />
|
||||||
<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>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full flex items-center justify-between p-4">
|
<div class="w-full flex items-center justify-end p-4">
|
||||||
<p v-if="enableOpenIDAuth" class="text-sm text-warning">{{ $strings.MessageAuthenticationOIDCChangesRestart }}</p>
|
|
||||||
<ui-btn color="bg-success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
|
<ui-btn color="bg-success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
|
||||||
</div>
|
</div>
|
||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
|
|
@ -156,171 +69,68 @@ export default {
|
||||||
enableOpenIDAuth: false,
|
enableOpenIDAuth: false,
|
||||||
showCustomLoginMessage: false,
|
showCustomLoginMessage: false,
|
||||||
savingSettings: false,
|
savingSettings: false,
|
||||||
openIdSigningAlgorithmsSupportedByIssuer: [],
|
openIDSchemaOverrides: {},
|
||||||
newAuthSettings: {}
|
newAuthSettings: {},
|
||||||
|
openIDValues: {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
authMethods() {
|
authMethods() {
|
||||||
return this.authSettings.authActiveAuthMethods || []
|
return this.authSettings.authActiveAuthMethods || []
|
||||||
},
|
},
|
||||||
matchingExistingOptions() {
|
openIDSchema() {
|
||||||
return [
|
return this.authSettings.openIDSettings?.schema || []
|
||||||
{
|
|
||||||
text: 'Do not match',
|
|
||||||
value: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Match by email',
|
|
||||||
value: 'email'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Match by username',
|
|
||||||
value: 'username'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
subfolderOptions() {
|
openIDGroups() {
|
||||||
const options = [
|
return this.authSettings.openIDSettings?.groups || []
|
||||||
{
|
|
||||||
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`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
autoPopulateOIDCClick() {
|
onOidcSettingChange({ key, value }) {
|
||||||
if (!this.newAuthSettings.authOpenIDIssuerURL) {
|
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')
|
this.$toast.error('Issuer URL required')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove trailing slash
|
// Remove trailing slash
|
||||||
let issuerUrl = this.newAuthSettings.authOpenIDIssuerURL
|
|
||||||
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
|
||||||
|
|
||||||
// If the full config path is on the issuer url then remove it
|
// If the full config path is on the issuer url then remove it
|
||||||
if (issuerUrl.endsWith('/.well-known/openid-configuration')) {
|
if (issuerUrl.endsWith('/.well-known/openid-configuration')) {
|
||||||
issuerUrl = issuerUrl.replace('/.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) => {
|
try {
|
||||||
if (!algorithms?.length || !Array.isArray(algorithms)) {
|
const data = await this.$axios.$post('/api/auth-settings/openid/discover', { issuerUrl })
|
||||||
console.warn('Invalid id_token_signing_alg_values_supported from openid-configuration', algorithms)
|
|
||||||
this.openIdSigningAlgorithmsSupportedByIssuer = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.openIdSigningAlgorithmsSupportedByIssuer = algorithms
|
|
||||||
|
|
||||||
// If a signing algorithm is already selected, then keep it, when it is still supported.
|
// Apply discovered values
|
||||||
// But if it is not supported, then select one of the supported ones.
|
if (data.values) {
|
||||||
let currentAlgorithm = this.newAuthSettings.authOpenIDTokenSigningAlgorithm
|
for (const [key, value] of Object.entries(data.values)) {
|
||||||
if (!algorithms.includes(currentAlgorithm)) {
|
if (value !== null && value !== undefined) {
|
||||||
this.newAuthSettings.authOpenIDTokenSigningAlgorithm = algorithms[0]
|
this.$set(this.openIDValues, key, value)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function isValidClaim(claim) {
|
// Apply schema overrides (e.g., supported signing algorithms)
|
||||||
if (claim === '') return true
|
if (data.schemaOverrides) {
|
||||||
|
this.openIDSchemaOverrides = data.schemaOverrides
|
||||||
const pattern = new RegExp('^[a-zA-Z][a-zA-Z0-9_-]*$', 'i')
|
}
|
||||||
return pattern.test(claim)
|
} 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)
|
||||||
}
|
}
|
||||||
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() {
|
async saveSettings() {
|
||||||
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
|
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
|
||||||
|
|
@ -328,42 +138,53 @@ export default {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.enableOpenIDAuth && !this.validateOpenID()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) {
|
if (!this.showCustomLoginMessage || !this.newAuthSettings.authLoginCustomMessage?.trim()) {
|
||||||
this.newAuthSettings.authLoginCustomMessage = null
|
this.newAuthSettings.authLoginCustomMessage = null
|
||||||
}
|
}
|
||||||
|
|
||||||
this.newAuthSettings.authActiveAuthMethods = []
|
const authActiveAuthMethods = []
|
||||||
if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local')
|
if (this.enableLocalAuth) authActiveAuthMethods.push('local')
|
||||||
if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid')
|
if (this.enableOpenIDAuth) authActiveAuthMethods.push('openid')
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
authLoginCustomMessage: this.newAuthSettings.authLoginCustomMessage,
|
||||||
|
authActiveAuthMethods,
|
||||||
|
openIDSettings: this.openIDValues
|
||||||
|
}
|
||||||
|
|
||||||
this.savingSettings = true
|
this.savingSettings = true
|
||||||
this.$axios
|
try {
|
||||||
.$patch('/api/auth-settings', this.newAuthSettings)
|
const data = await this.$axios.$patch('/api/auth-settings', payload)
|
||||||
.then((data) => {
|
this.$store.commit('setServerSettings', data.serverSettings)
|
||||||
this.$store.commit('setServerSettings', data.serverSettings)
|
if (data.updated) {
|
||||||
if (data.updated) {
|
this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess)
|
||||||
this.$toast.success(this.$strings.ToastServerSettingsUpdateSuccess)
|
} else {
|
||||||
} else {
|
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
||||||
this.$toast.info(this.$strings.MessageNoUpdatesWereNecessary)
|
}
|
||||||
}
|
} catch (error) {
|
||||||
})
|
console.error('Failed to update server settings', error)
|
||||||
.catch((error) => {
|
if (error.response?.data?.details) {
|
||||||
console.error('Failed to update server settings', error)
|
error.response.data.details.forEach((detail) => this.$toast.error(detail))
|
||||||
|
} else {
|
||||||
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
this.$toast.error(this.$strings.ToastFailedToUpdate)
|
||||||
})
|
}
|
||||||
.finally(() => {
|
} finally {
|
||||||
this.savingSettings = false
|
this.savingSettings = false
|
||||||
})
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
this.newAuthSettings = {
|
this.newAuthSettings = {
|
||||||
...this.authSettings,
|
authLoginCustomMessage: this.authSettings.authLoginCustomMessage,
|
||||||
authOpenIDSubfolderForRedirectURLs: this.authSettings.authOpenIDSubfolderForRedirectURLs === undefined ? this.$config.routerBasePath : this.authSettings.authOpenIDSubfolderForRedirectURLs
|
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
|
||||||
|
}
|
||||||
|
|
||||||
this.enableLocalAuth = this.authMethods.includes('local')
|
this.enableLocalAuth = this.authMethods.includes('local')
|
||||||
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
this.enableOpenIDAuth = this.authMethods.includes('openid')
|
||||||
this.showCustomLoginMessage = !!this.authSettings.authLoginCustomMessage
|
this.showCustomLoginMessage = !!this.authSettings.authLoginCustomMessage
|
||||||
|
|
|
||||||
239
server/Auth.js
239
server/Auth.js
|
|
@ -107,6 +107,7 @@ class Auth {
|
||||||
// #region Passport strategies
|
// #region Passport strategies
|
||||||
/**
|
/**
|
||||||
* Inializes all passportjs strategies and other passportjs ralated initialization.
|
* Inializes all passportjs strategies and other passportjs ralated initialization.
|
||||||
|
* Note: OIDC no longer uses passport - only local auth and JWT use it.
|
||||||
*/
|
*/
|
||||||
async initPassportJs() {
|
async initPassportJs() {
|
||||||
// Check if we should load the local strategy (username + password login)
|
// Check if we should load the local strategy (username + password login)
|
||||||
|
|
@ -114,10 +115,7 @@ class Auth {
|
||||||
this.localAuthStrategy.init()
|
this.localAuthStrategy.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we should load the openid strategy
|
// OIDC no longer needs passport initialization - it handles tokens directly
|
||||||
if (global.ServerSettings.authActiveAuthMethods.includes('openid')) {
|
|
||||||
this.oidcAuthStrategy.init()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the JwtStrategy (always) -> for bearer token auth
|
// Load the JwtStrategy (always) -> for bearer token auth
|
||||||
passport.use(
|
passport.use(
|
||||||
|
|
@ -168,7 +166,7 @@ class Auth {
|
||||||
*/
|
*/
|
||||||
unuseAuthStrategy(name) {
|
unuseAuthStrategy(name) {
|
||||||
if (name === 'openid') {
|
if (name === 'openid') {
|
||||||
this.oidcAuthStrategy.unuse()
|
this.oidcAuthStrategy.reload()
|
||||||
} else if (name === 'local') {
|
} else if (name === 'local') {
|
||||||
this.localAuthStrategy.unuse()
|
this.localAuthStrategy.unuse()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -183,7 +181,7 @@ class Auth {
|
||||||
*/
|
*/
|
||||||
useAuthStrategy(name) {
|
useAuthStrategy(name) {
|
||||||
if (name === 'openid') {
|
if (name === 'openid') {
|
||||||
this.oidcAuthStrategy.init()
|
this.oidcAuthStrategy.reload()
|
||||||
} else if (name === 'local') {
|
} else if (name === 'local') {
|
||||||
this.localAuthStrategy.init()
|
this.localAuthStrategy.init()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -202,84 +200,7 @@ class Auth {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stores the client's choice of login callback method in temporary cookies.
|
* After login success from local auth
|
||||||
*
|
|
||||||
* 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
|
|
||||||
* req.user is set by passport.authenticate
|
* req.user is set by passport.authenticate
|
||||||
*
|
*
|
||||||
* attaches the access token to the user in the response
|
* attaches the access token to the user in the response
|
||||||
|
|
@ -318,6 +239,9 @@ class Auth {
|
||||||
async initAuthRoutes(router) {
|
async initAuthRoutes(router) {
|
||||||
// Local strategy login route (takes username and password)
|
// Local strategy login route (takes username and password)
|
||||||
router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => {
|
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
|
// Check if mobile app wants refresh token in response
|
||||||
const returnTokens = req.headers['x-return-tokens'] === 'true'
|
const returnTokens = req.headers['x-return-tokens'] === 'true'
|
||||||
|
|
||||||
|
|
@ -358,18 +282,26 @@ class Auth {
|
||||||
|
|
||||||
// openid strategy login route (this redirects to the configured openid login provider)
|
// openid strategy login route (this redirects to the configured openid login provider)
|
||||||
router.get('/auth/openid', this.authRateLimiter, (req, res) => {
|
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 (!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' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req, isMobileFlow, callback)
|
||||||
|
|
||||||
if (authorizationUrlResponse.error) {
|
if (authorizationUrlResponse.error) {
|
||||||
return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error)
|
return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
res.redirect(authorizationUrlResponse.authorizationUrl)
|
res.redirect(authorizationUrlResponse.authorizationUrl)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -377,77 +309,66 @@ class Auth {
|
||||||
// It will redirect to an app-link like audiobookshelf://oauth
|
// 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))
|
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)
|
// openid strategy callback route - now uses direct token exchange (no passport)
|
||||||
router.get(
|
router.get('/auth/openid/callback', this.authRateLimiter, async (req, res) => {
|
||||||
'/auth/openid/callback',
|
const isMobile = !!req.session.oidc?.isMobile
|
||||||
this.authRateLimiter,
|
// Extract session data before cleanup (needed for redirect on success)
|
||||||
(req, res, next) => {
|
const callbackUrl = req.session.oidc?.callbackUrl
|
||||||
const sessionKey = this.oidcAuthStrategy.getStrategy()._key
|
|
||||||
|
|
||||||
if (!req.session[sessionKey]) {
|
try {
|
||||||
return res.status(400).send('No session')
|
const user = await this.oidcAuthStrategy.handleCallback(req)
|
||||||
|
|
||||||
|
// req.login still works (passport initialized for JWT/local)
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
req.login(user, (err) => (err ? reject(err) : resolve()))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create tokens and session, storing oidcIdToken in DB
|
||||||
|
const returnTokens = isMobile
|
||||||
|
const { accessToken, refreshToken } = await this.tokenManager.createTokensAndSession(user, req, user.openid_id_token)
|
||||||
|
|
||||||
|
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
|
if (isMobile) {
|
||||||
// The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
|
res.json(userResponse)
|
||||||
// Crucial for API/Mobile clients
|
} else {
|
||||||
if (req.query.code_verifier) {
|
if (callbackUrl) {
|
||||||
req.session[sessionKey].code_verifier = req.query.code_verifier
|
// TODO: Temporarily continue sending the old token as setToken
|
||||||
}
|
res.redirect(302, `${callbackUrl}?setToken=${userResponse.user.token}&accessToken=${accessToken}`)
|
||||||
|
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
|
res.status(400).send('No callback URL')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
function passportCallback(req, res, next) {
|
Logger.error(`[Auth] OIDC callback error: ${error.message}\n${error.stack}`)
|
||||||
return (err, user, info) => {
|
if (isMobile) {
|
||||||
const isMobile = req.session[sessionKey]?.mobile === true
|
res.status(error.statusCode || 500).json({ error: error.message })
|
||||||
if (err) {
|
} else {
|
||||||
return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
|
res.redirect(`${global.RouterBasePath}/login?error=${encodeURIComponent(error.message)}&autoLaunch=0`)
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
|
// Clean up OIDC session data to prevent replay (on both success and error paths)
|
||||||
// We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
|
delete req.session.oidc
|
||||||
// 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)
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @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
|
* 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"
|
* Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration"
|
||||||
*
|
*
|
||||||
|
|
@ -473,7 +394,7 @@ class Auth {
|
||||||
|
|
||||||
// Logout route
|
// Logout route
|
||||||
router.post('/logout', async (req, res) => {
|
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']
|
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
|
||||||
|
|
||||||
// Clear refresh token cookie
|
// Clear refresh token cookie
|
||||||
|
|
@ -481,8 +402,13 @@ class Auth {
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Invalidate the session in database using refresh token
|
// Get oidcIdToken from DB session before invalidating (for OIDC logout)
|
||||||
|
let oidcIdToken = null
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
|
const session = await this.tokenManager.getSessionByRefreshToken(refreshToken)
|
||||||
|
if (session) {
|
||||||
|
oidcIdToken = session.oidcIdToken
|
||||||
|
}
|
||||||
await this.tokenManager.invalidateRefreshToken(refreshToken)
|
await this.tokenManager.invalidateRefreshToken(refreshToken)
|
||||||
} else {
|
} else {
|
||||||
Logger.info(`[Auth] logout: No refresh token on request`)
|
Logger.info(`[Auth] logout: No refresh token on request`)
|
||||||
|
|
@ -499,8 +425,7 @@ class Auth {
|
||||||
let logoutUrl = null
|
let logoutUrl = null
|
||||||
|
|
||||||
if (authMethod === 'openid' || authMethod === 'openid-mobile') {
|
if (authMethod === 'openid' || authMethod === 'openid-mobile') {
|
||||||
logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod)
|
logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, oidcIdToken, authMethod)
|
||||||
res.clearCookie('openid_id_token')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the user agent (browser) to redirect to the authentification provider's logout URL
|
// Tell the user agent (browser) to redirect to the authentification provider's logout URL
|
||||||
|
|
|
||||||
9
server/auth/AuthError.js
Normal file
9
server/auth/AuthError.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
class AuthError extends Error {
|
||||||
|
constructor(message, statusCode = 500) {
|
||||||
|
super(message)
|
||||||
|
this.statusCode = statusCode
|
||||||
|
this.name = 'AuthError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = AuthError
|
||||||
|
|
@ -1,42 +1,20 @@
|
||||||
const { Request, Response } = require('express')
|
const { Request, Response } = require('express')
|
||||||
const passport = require('passport')
|
|
||||||
const OpenIDClient = require('openid-client')
|
const OpenIDClient = require('openid-client')
|
||||||
const axios = require('axios')
|
const axios = require('axios')
|
||||||
const Database = require('../Database')
|
const Database = require('../Database')
|
||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
const AuthError = require('./AuthError')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenID Connect authentication strategy
|
* OpenID Connect authentication strategy (no Passport wrapper)
|
||||||
*/
|
*/
|
||||||
class OidcAuthStrategy {
|
class OidcAuthStrategy {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.name = 'openid-client'
|
|
||||||
this.strategy = null
|
|
||||||
this.client = null
|
this.client = null
|
||||||
// Map of openId sessions indexed by oauth2 state-variable
|
// Map of openId sessions indexed by oauth2 state-variable
|
||||||
this.openIdAuthSession = new Map()
|
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
|
* Get the OpenID Connect client
|
||||||
* @returns {OpenIDClient.Client}
|
* @returns {OpenIDClient.Client}
|
||||||
|
|
@ -44,7 +22,7 @@ class OidcAuthStrategy {
|
||||||
getClient() {
|
getClient() {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
|
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
|
// Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
|
||||||
|
|
@ -73,60 +51,99 @@ class OidcAuthStrategy {
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
getScope() {
|
getScope() {
|
||||||
let scope = 'openid profile email'
|
return global.ServerSettings.authOpenIDScopes || 'openid profile email'
|
||||||
if (global.ServerSettings.authOpenIDGroupClaim) {
|
|
||||||
scope += ' ' + global.ServerSettings.authOpenIDGroupClaim
|
|
||||||
}
|
|
||||||
if (global.ServerSettings.authOpenIDAdvancedPermsClaim) {
|
|
||||||
scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim
|
|
||||||
}
|
|
||||||
return scope
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the strategy with passport
|
* Reload the OIDC strategy after settings change (replaces init/unuse)
|
||||||
*/
|
*/
|
||||||
init() {
|
reload() {
|
||||||
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
|
|
||||||
this.client = null
|
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
|
||||||
|
*/
|
||||||
|
cleanupStaleAuthSessions() {
|
||||||
|
const maxAge = 10 * 60 * 1000 // 10 minutes
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [state, session] of this.openIdAuthSession) {
|
||||||
|
if (now - (session.created_at || 0) > maxAge) {
|
||||||
|
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<import('../models/User')>} authenticated user
|
||||||
|
* @throws {AuthError}
|
||||||
|
*/
|
||||||
|
async handleCallback(req) {
|
||||||
|
const sessionData = req.session.oidc
|
||||||
|
if (!sessionData) {
|
||||||
|
throw new AuthError('No OIDC session found', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = this.getClient()
|
||||||
|
|
||||||
|
// If the client sends a code_verifier in query, use it (mobile flow)
|
||||||
|
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,
|
||||||
|
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)
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify user from OIDC token set and userinfo.
|
||||||
|
* Returns user directly or throws AuthError.
|
||||||
|
*
|
||||||
* @param {Object} tokenset
|
* @param {Object} tokenset
|
||||||
* @param {Object} userinfo
|
* @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 isNewUser = false
|
||||||
let user = null
|
let user = null
|
||||||
try {
|
try {
|
||||||
Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
||||||
|
|
||||||
if (!userinfo.sub) {
|
if (!userinfo.sub) {
|
||||||
throw new Error('Invalid userinfo, no sub')
|
throw new AuthError('Invalid userinfo, no sub', 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.validateGroupClaim(userinfo)) {
|
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 === false) {
|
||||||
|
throw new AuthError('Email is not verified', 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
user = await Database.userModel.findUserFromOpenIdUserInfo(userinfo)
|
user = await Database.userModel.findUserFromOpenIdUserInfo(userinfo)
|
||||||
|
|
||||||
if (user?.error) {
|
if (user?.error) {
|
||||||
throw new Error('Invalid userinfo or already linked')
|
throw new AuthError('Invalid userinfo or already linked', 401)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|
@ -137,27 +154,31 @@ class OidcAuthStrategy {
|
||||||
isNewUser = true
|
isNewUser = true
|
||||||
} else {
|
} else {
|
||||||
Logger.warn(`[User] openid: User not found and auto-register is disabled`)
|
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) {
|
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.setUserGroup(user, userinfo)
|
||||||
await this.updateUserPermissions(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
|
user.openid_id_token = tokenset.id_token
|
||||||
|
|
||||||
return done(null, user)
|
return user
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`)
|
Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`)
|
||||||
// Remove new user if an error occurs
|
// Remove new user if an error occurs
|
||||||
if (isNewUser && user) {
|
if (isNewUser && user) {
|
||||||
await user.destroy()
|
await user.destroy()
|
||||||
}
|
}
|
||||||
return done(null, null, 'Unauthorized')
|
if (error instanceof AuthError) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
throw new AuthError(error.message || 'Unauthorized', 401)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,6 +202,8 @@ class OidcAuthStrategy {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the user group based on group claim in userinfo.
|
* 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 {import('../models/User')} user
|
||||||
* @param {Object} userinfo
|
* @param {Object} userinfo
|
||||||
*/
|
*/
|
||||||
|
|
@ -190,17 +213,35 @@ class OidcAuthStrategy {
|
||||||
// No group claim configured, don't set anything
|
// No group claim configured, don't set anything
|
||||||
return
|
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 groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
||||||
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
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 (userType) {
|
||||||
if (user.type === 'root') {
|
if (user.type === 'root') {
|
||||||
// Check OpenID Group
|
// Check OpenID Group
|
||||||
if (userType !== 'admin') {
|
if (userType !== 'admin') {
|
||||||
throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`)
|
throw new AuthError(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`, 403)
|
||||||
} else {
|
} else {
|
||||||
// If root user is logging in via OpenID, we will not change the type
|
// If root user is logging in via OpenID, we will not change the type
|
||||||
return
|
return
|
||||||
|
|
@ -213,7 +254,7 @@ class OidcAuthStrategy {
|
||||||
await user.save()
|
await user.save()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`)
|
throw new AuthError(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`, 401)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,7 +272,7 @@ class OidcAuthStrategy {
|
||||||
if (user.type === 'admin' || user.type === 'root') return
|
if (user.type === 'admin' || user.type === 'root') return
|
||||||
|
|
||||||
const absPermissions = userinfo[absPermissionsClaim]
|
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)) {
|
if (await user.updatePermissionsFromExternalJSON(absPermissions)) {
|
||||||
Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
|
Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
|
||||||
|
|
@ -274,24 +315,23 @@ class OidcAuthStrategy {
|
||||||
*/
|
*/
|
||||||
isValidRedirectUri(uri) {
|
isValidRedirectUri(uri) {
|
||||||
// Check if the redirect_uri is in the whitelist
|
// 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
|
* Get the authorization URL for OpenID Connect
|
||||||
* Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow
|
* Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow
|
||||||
* @param {Request} req
|
* @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 client = this.getClient()
|
||||||
const strategy = this.getStrategy()
|
|
||||||
const sessionKey = strategy._key
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||||
const hostUrl = new URL(`${protocol}://${req.get('host')}`)
|
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)
|
// Only allow code flow (for mobile clients)
|
||||||
if (req.query.response_type && req.query.response_type !== 'code') {
|
if (req.query.response_type && req.query.response_type !== 'code') {
|
||||||
|
|
@ -309,8 +349,6 @@ class OidcAuthStrategy {
|
||||||
let redirectUri
|
let redirectUri
|
||||||
if (isMobileFlow) {
|
if (isMobileFlow) {
|
||||||
// Mobile required redirect uri
|
// 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)) {
|
if (!req.query.redirect_uri || !this.isValidRedirectUri(req.query.redirect_uri)) {
|
||||||
Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)
|
Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`)
|
||||||
return {
|
return {
|
||||||
|
|
@ -318,9 +356,10 @@ class OidcAuthStrategy {
|
||||||
error: 'Invalid redirect_uri'
|
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
|
// We cannot save the supplied redirect_uri in the session, because the mobile client uses browser instead of the API
|
||||||
// for the request to mobile-redirect and as such the session is not shared
|
// 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 })
|
this.cleanupStaleAuthSessions()
|
||||||
|
this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri, created_at: Date.now() })
|
||||||
|
|
||||||
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString()
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -335,8 +374,6 @@ class OidcAuthStrategy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the strategy's redirect_uri for this request
|
|
||||||
strategy._params.redirect_uri = redirectUri
|
|
||||||
Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)
|
Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`)
|
||||||
|
|
||||||
const pkceData = this.generatePkce(req, isMobileFlow)
|
const pkceData = this.generatePkce(req, isMobileFlow)
|
||||||
|
|
@ -347,18 +384,17 @@ class OidcAuthStrategy {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.session[sessionKey] = {
|
// Store OIDC session data using fixed key 'oidc'
|
||||||
...req.session[sessionKey],
|
req.session.oidc = {
|
||||||
state: state,
|
state: state,
|
||||||
max_age: strategy._params.max_age,
|
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
code_verifier: pkceData.code_verifier, // not null if web flow
|
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
|
isMobile: !!isMobileFlow,
|
||||||
sso_redirect_uri: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback
|
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({
|
const authorizationUrl = client.authorizationUrl({
|
||||||
...strategy._params,
|
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
state: state,
|
state: state,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
|
|
@ -396,18 +432,11 @@ class OidcAuthStrategy {
|
||||||
if (authMethod === 'openid') {
|
if (authMethod === 'openid') {
|
||||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||||
const host = req.get('host')
|
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`
|
postLogoutRedirectUri = `${protocol}://${host}${global.RouterBasePath}/login`
|
||||||
}
|
}
|
||||||
// else for openid-mobile we keep postLogoutRedirectUri on null
|
// 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 client/app can simply append something like
|
||||||
// the post_logout_redirect_uri parameter at all and for other providers
|
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url
|
||||||
// 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)
|
|
||||||
|
|
||||||
return client.endSessionUrl({
|
return client.endSessionUrl({
|
||||||
id_token_hint: idToken,
|
id_token_hint: idToken,
|
||||||
|
|
|
||||||
340
server/auth/OidcSettingsSchema.js
Normal file
340
server/auth/OidcSettingsSchema.js
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
const groups = [
|
||||||
|
{ id: 'endpoints', label: 'Provider Endpoints', order: 1 },
|
||||||
|
{ id: 'credentials', label: 'Client Credentials', order: 2 },
|
||||||
|
{ id: 'behavior', label: 'Login Behavior', order: 3 },
|
||||||
|
{ id: 'claims', label: 'Claims & Group Mapping', order: 4 },
|
||||||
|
{ id: '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',
|
||||||
|
description: 'Name of the claim containing group membership'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
description: 'Claim containing per-user permissions JSON'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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)'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
description: `Claim containing per-user permissions JSON. Sample: ${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 }
|
||||||
|
|
@ -156,9 +156,10 @@ class TokenManager {
|
||||||
*
|
*
|
||||||
* @param {{ id:string, username:string }} user
|
* @param {{ id:string, username:string }} user
|
||||||
* @param {import('express').Request} req
|
* @param {import('express').Request} req
|
||||||
|
* @param {string|null} [oidcIdToken=null] - OIDC id_token to store in session for logout
|
||||||
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}
|
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}
|
||||||
*/
|
*/
|
||||||
async createTokensAndSession(user, req) {
|
async createTokensAndSession(user, req, oidcIdToken = null) {
|
||||||
const ipAddress = requestIp.getClientIp(req)
|
const ipAddress = requestIp.getClientIp(req)
|
||||||
const userAgent = req.headers['user-agent']
|
const userAgent = req.headers['user-agent']
|
||||||
const accessToken = this.generateTempAccessToken(user)
|
const accessToken = this.generateTempAccessToken(user)
|
||||||
|
|
@ -167,7 +168,7 @@ class TokenManager {
|
||||||
// Calculate expiration time for the refresh token
|
// Calculate expiration time for the refresh token
|
||||||
const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
|
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)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accessToken,
|
accessToken,
|
||||||
|
|
@ -392,6 +393,17 @@ class TokenManager {
|
||||||
return null
|
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
|
* Invalidate a refresh token - used for logout
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const { sanitizeFilename } = require('../utils/fileUtils')
|
||||||
|
|
||||||
const TaskManager = require('../managers/TaskManager')
|
const TaskManager = require('../managers/TaskManager')
|
||||||
const adminStats = require('../utils/queries/adminStats')
|
const adminStats = require('../utils/queries/adminStats')
|
||||||
|
const OidcSettingsSchema = require('../auth/OidcSettingsSchema')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef RequestUserObject
|
* @typedef RequestUserObject
|
||||||
|
|
@ -625,7 +626,16 @@ class MiscController {
|
||||||
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`)
|
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`)
|
||||||
return res.sendStatus(403)
|
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
|
let hasUpdates = false
|
||||||
|
|
||||||
const currentAuthenticationSettings = Database.serverSettings.authenticationSettings
|
const originalAuthMethods = [...Database.serverSettings.authActiveAuthMethods]
|
||||||
const originalAuthMethods = [...currentAuthenticationSettings.authActiveAuthMethods]
|
const originalLoginMessage = Database.serverSettings.authLoginCustomMessage
|
||||||
|
|
||||||
// TODO: Better validation of auth settings once auth settings are separated from server settings
|
// 1. Update static settings (authLoginCustomMessage, authActiveAuthMethods)
|
||||||
for (const key in currentAuthenticationSettings) {
|
if (settingsUpdate.authLoginCustomMessage !== undefined) {
|
||||||
if (settingsUpdate[key] === undefined) continue
|
const newValue = settingsUpdate.authLoginCustomMessage || null
|
||||||
|
if (newValue !== Database.serverSettings.authLoginCustomMessage) {
|
||||||
|
Database.serverSettings.authLoginCustomMessage = newValue
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (key === 'authActiveAuthMethods') {
|
if (settingsUpdate.authActiveAuthMethods !== undefined) {
|
||||||
let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))
|
let updatedAuthMethods = settingsUpdate.authActiveAuthMethods?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))
|
||||||
if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {
|
if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {
|
||||||
updatedAuthMethods.sort()
|
updatedAuthMethods.sort()
|
||||||
currentAuthenticationSettings[key].sort()
|
const currentSorted = [...Database.serverSettings.authActiveAuthMethods].sort()
|
||||||
if (updatedAuthMethods.join() !== currentAuthenticationSettings[key].join()) {
|
if (updatedAuthMethods.join() !== currentSorted.join()) {
|
||||||
Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentAuthenticationSettings[key].join()}" to "${updatedAuthMethods.join()}"`)
|
Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentSorted.join()}" to "${updatedAuthMethods.join()}"`)
|
||||||
Database.serverSettings[key] = updatedAuthMethods
|
Database.serverSettings.authActiveAuthMethods = 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
|
|
||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const updatedValueType = typeof settingsUpdate[key]
|
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
|
||||||
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
|
|
||||||
|
|
||||||
if (updatedValue !== currentValue) {
|
// Reject enabling openid without valid OIDC configuration
|
||||||
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
|
if (Database.serverSettings.authActiveAuthMethods.includes('openid') && !originalAuthMethods.includes('openid')) {
|
||||||
Database.serverSettings[key] = updatedValue
|
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
|
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) {
|
if (hasUpdates) {
|
||||||
await Database.updateServerSettings()
|
await Database.updateServerSettings()
|
||||||
|
|
||||||
// Use/unuse auth methods
|
// Use/unuse auth methods (this calls reload() for newly enabled/disabled openid)
|
||||||
Database.serverSettings.supportedAuthMethods.forEach((authMethod) => {
|
Database.serverSettings.supportedAuthMethods.forEach((authMethod) => {
|
||||||
if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
|
if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
|
||||||
// Auth method has been removed
|
// 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
|
* GET: /api/stats/year/:year
|
||||||
*
|
*
|
||||||
|
|
|
||||||
143
server/migrations/v2.33.0-oidc-scopes-and-group-map.js
Normal file
143
server/migrations/v2.33.0-oidc-scopes-and-group-map.js
Normal 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 }
|
||||||
|
|
@ -18,6 +18,8 @@ class Session extends Model {
|
||||||
this.userId
|
this.userId
|
||||||
/** @type {Date} */
|
/** @type {Date} */
|
||||||
this.expiresAt
|
this.expiresAt
|
||||||
|
/** @type {string} */
|
||||||
|
this.oidcIdToken
|
||||||
|
|
||||||
// Expanded properties
|
// Expanded properties
|
||||||
|
|
||||||
|
|
@ -25,8 +27,8 @@ class Session extends Model {
|
||||||
this.user
|
this.user
|
||||||
}
|
}
|
||||||
|
|
||||||
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) {
|
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken = null) {
|
||||||
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt })
|
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt, oidcIdToken })
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,7 +68,8 @@ class Session extends Model {
|
||||||
expiresAt: {
|
expiresAt: {
|
||||||
type: DataTypes.DATE,
|
type: DataTypes.DATE,
|
||||||
allowNull: false
|
allowNull: false
|
||||||
}
|
},
|
||||||
|
oidcIdToken: DataTypes.TEXT
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize,
|
sequelize,
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,9 @@ class ServerSettings {
|
||||||
this.authOpenIDGroupClaim = ''
|
this.authOpenIDGroupClaim = ''
|
||||||
this.authOpenIDAdvancedPermsClaim = ''
|
this.authOpenIDAdvancedPermsClaim = ''
|
||||||
this.authOpenIDSubfolderForRedirectURLs = undefined
|
this.authOpenIDSubfolderForRedirectURLs = undefined
|
||||||
|
this.authOpenIDScopes = 'openid profile email'
|
||||||
|
this.authOpenIDGroupMap = {}
|
||||||
|
this.authOpenIDRequireVerifiedEmail = false
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
this.construct(settings)
|
this.construct(settings)
|
||||||
|
|
@ -146,6 +149,9 @@ class ServerSettings {
|
||||||
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
||||||
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
||||||
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
|
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
|
||||||
|
this.authOpenIDScopes = settings.authOpenIDScopes || 'openid profile email'
|
||||||
|
this.authOpenIDGroupMap = settings.authOpenIDGroupMap || {}
|
||||||
|
this.authOpenIDRequireVerifiedEmail = !!settings.authOpenIDRequireVerifiedEmail
|
||||||
|
|
||||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||||
this.authActiveAuthMethods = ['local']
|
this.authActiveAuthMethods = ['local']
|
||||||
|
|
@ -255,7 +261,10 @@ class ServerSettings {
|
||||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
||||||
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
||||||
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,6 +276,9 @@ class ServerSettings {
|
||||||
delete json.authOpenIDMobileRedirectURIs
|
delete json.authOpenIDMobileRedirectURIs
|
||||||
delete json.authOpenIDGroupClaim
|
delete json.authOpenIDGroupClaim
|
||||||
delete json.authOpenIDAdvancedPermsClaim
|
delete json.authOpenIDAdvancedPermsClaim
|
||||||
|
delete json.authOpenIDScopes
|
||||||
|
delete json.authOpenIDGroupMap
|
||||||
|
delete json.authOpenIDRequireVerifiedEmail
|
||||||
return json
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,29 +293,41 @@ class ServerSettings {
|
||||||
return this.authOpenIDIssuerURL && this.authOpenIDAuthorizationURL && this.authOpenIDTokenURL && this.authOpenIDUserInfoURL && this.authOpenIDJwksURL && this.authOpenIDClientID && this.authOpenIDClientSecret && this.authOpenIDTokenSigningAlgorithm
|
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 {
|
return {
|
||||||
authLoginCustomMessage: this.authLoginCustomMessage,
|
|
||||||
authActiveAuthMethods: this.authActiveAuthMethods,
|
|
||||||
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
|
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
|
||||||
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
|
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
|
||||||
authOpenIDTokenURL: this.authOpenIDTokenURL,
|
authOpenIDTokenURL: this.authOpenIDTokenURL,
|
||||||
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
|
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
|
||||||
authOpenIDJwksURL: this.authOpenIDJwksURL,
|
authOpenIDJwksURL: this.authOpenIDJwksURL,
|
||||||
authOpenIDLogoutURL: this.authOpenIDLogoutURL,
|
authOpenIDLogoutURL: this.authOpenIDLogoutURL,
|
||||||
authOpenIDClientID: this.authOpenIDClientID, // Do not return to client
|
authOpenIDClientID: this.authOpenIDClientID,
|
||||||
authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client
|
authOpenIDClientSecret: this.authOpenIDClientSecret,
|
||||||
authOpenIDTokenSigningAlgorithm: this.authOpenIDTokenSigningAlgorithm,
|
authOpenIDTokenSigningAlgorithm: this.authOpenIDTokenSigningAlgorithm,
|
||||||
authOpenIDButtonText: this.authOpenIDButtonText,
|
authOpenIDButtonText: this.authOpenIDButtonText,
|
||||||
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
|
||||||
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
|
||||||
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy,
|
||||||
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client
|
authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs,
|
||||||
authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client
|
authOpenIDGroupClaim: this.authOpenIDGroupClaim,
|
||||||
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client
|
authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim,
|
||||||
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
|
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
|
||||||
|
authOpenIDScopes: this.authOpenIDScopes,
|
||||||
|
authOpenIDGroupMap: this.authOpenIDGroupMap,
|
||||||
|
authOpenIDRequireVerifiedEmail: this.authOpenIDRequireVerifiedEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
|
get authenticationSettings() {
|
||||||
|
return {
|
||||||
|
authLoginCustomMessage: this.authLoginCustomMessage,
|
||||||
|
authActiveAuthMethods: this.authActiveAuthMethods,
|
||||||
|
openIDSettings: {
|
||||||
|
values: this.openIDSettingsValues
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,7 @@ class ApiRouter {
|
||||||
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
|
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
|
||||||
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
|
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
|
||||||
this.router.patch('/auth-settings', MiscController.updateAuthSettings.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.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
|
||||||
this.router.get('/logger-data', MiscController.getLoggerData.bind(this))
|
this.router.get('/logger-data', MiscController.getLoggerData.bind(this))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
test/server/auth/AuthError.test.js
Normal file
24
test/server/auth/AuthError.test.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
246
test/server/auth/OidcAuthStrategy.test.js
Normal file
246
test/server/auth/OidcAuthStrategy.test.js
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const AuthError = require('../../../server/auth/AuthError')
|
||||||
|
|
||||||
|
// We test setUserGroup in isolation by creating a minimal instance
|
||||||
|
// and stubbing the globals it depends on
|
||||||
|
describe('OidcAuthStrategy - setUserGroup', function () {
|
||||||
|
let OidcAuthStrategy, strategy
|
||||||
|
|
||||||
|
before(function () {
|
||||||
|
// Stub global dependencies that OidcAuthStrategy requires at import time
|
||||||
|
global.ServerSettings = {
|
||||||
|
authOpenIDGroupClaim: '',
|
||||||
|
authOpenIDGroupMap: {},
|
||||||
|
authOpenIDScopes: 'openid profile email',
|
||||||
|
isOpenIDAuthSettingsValid: false,
|
||||||
|
authOpenIDMobileRedirectURIs: []
|
||||||
|
}
|
||||||
|
// Stub Database to avoid requiring sequelize
|
||||||
|
const Database = { serverSettings: global.ServerSettings }
|
||||||
|
const mod = require('module')
|
||||||
|
const originalResolve = mod._resolveFilename
|
||||||
|
// We need to require the actual file, but it imports Database and Logger
|
||||||
|
// Use proxyquire-style approach: clear cache and provide stubs
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
// Create a fresh instance for each test by directly constructing the class
|
||||||
|
// Since the module has complex imports, we test the logic directly
|
||||||
|
strategy = {
|
||||||
|
setUserGroup: async function (user, userinfo) {
|
||||||
|
const groupClaimName = global.ServerSettings.authOpenIDGroupClaim
|
||||||
|
if (!groupClaimName) return
|
||||||
|
|
||||||
|
if (!userinfo[groupClaimName]) throw new AuthError(`Group claim ${groupClaimName} not found in userinfo`, 401)
|
||||||
|
|
||||||
|
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
||||||
|
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||||
|
const groupMap = global.ServerSettings.authOpenIDGroupMap || {}
|
||||||
|
|
||||||
|
let userType = null
|
||||||
|
|
||||||
|
if (Object.keys(groupMap).length > 0) {
|
||||||
|
for (const role of rolesInOrderOfPriority) {
|
||||||
|
const mappedGroups = Object.entries(groupMap)
|
||||||
|
.filter(([, v]) => v === role)
|
||||||
|
.map(([k]) => k.toLowerCase())
|
||||||
|
if (mappedGroups.some((g) => groupsList.includes(g))) {
|
||||||
|
userType = role
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userType) {
|
||||||
|
if (user.type === 'root') {
|
||||||
|
if (userType !== 'admin') {
|
||||||
|
throw new AuthError(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`, 403)
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (user.type !== userType) {
|
||||||
|
user.type = userType
|
||||||
|
await user.save()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new AuthError(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`, 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = ''
|
||||||
|
global.ServerSettings.authOpenIDGroupMap = {}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('legacy direct name match (empty groupMap)', function () {
|
||||||
|
it('should assign admin role when group list includes admin', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||||
|
global.ServerSettings.authOpenIDGroupMap = {}
|
||||||
|
|
||||||
|
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { groups: ['Admin', 'Users'] }
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect(user.type).to.equal('admin')
|
||||||
|
expect(user.save.calledOnce).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should assign user role when group list includes user but not admin', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||||
|
global.ServerSettings.authOpenIDGroupMap = {}
|
||||||
|
|
||||||
|
const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { groups: ['User', 'Guests'] }
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect(user.type).to.equal('user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw when no valid group found', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||||
|
global.ServerSettings.authOpenIDGroupMap = {}
|
||||||
|
|
||||||
|
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { groups: ['unknown-group'] }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect.fail('Should have thrown')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).to.be.instanceOf(AuthError)
|
||||||
|
expect(error.statusCode).to.equal(401)
|
||||||
|
expect(error.message).to.include('No valid group found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('explicit group mapping', function () {
|
||||||
|
it('should map custom group names to roles', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||||
|
global.ServerSettings.authOpenIDGroupMap = {
|
||||||
|
'oidc-admins': 'admin',
|
||||||
|
'oidc-users': 'user',
|
||||||
|
'oidc-guests': 'guest'
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { groups: ['oidc-users'] }
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect(user.type).to.equal('user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prioritize admin over user', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||||
|
global.ServerSettings.authOpenIDGroupMap = {
|
||||||
|
'team-leads': 'admin',
|
||||||
|
'developers': 'user'
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { groups: ['developers', 'team-leads'] }
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect(user.type).to.equal('admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be case-insensitive for group matching', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||||
|
global.ServerSettings.authOpenIDGroupMap = {
|
||||||
|
'MyAdmins': 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { groups: ['myadmins'] }
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect(user.type).to.equal('admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw when no mapped group matches', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||||
|
global.ServerSettings.authOpenIDGroupMap = {
|
||||||
|
'admins': 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { groups: ['random-group'] }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect.fail('Should have thrown')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).to.be.instanceOf(AuthError)
|
||||||
|
expect(error.statusCode).to.equal(401)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('root user protection', function () {
|
||||||
|
it('should not downgrade root user to non-admin', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||||
|
global.ServerSettings.authOpenIDGroupMap = {}
|
||||||
|
|
||||||
|
const user = { type: 'root', username: 'root', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { groups: ['user'] }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect.fail('Should have thrown')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).to.be.instanceOf(AuthError)
|
||||||
|
expect(error.statusCode).to.equal(403)
|
||||||
|
expect(error.message).to.include('cannot be downgraded')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow root user with admin group (no change)', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||||
|
global.ServerSettings.authOpenIDGroupMap = {}
|
||||||
|
|
||||||
|
const user = { type: 'root', username: 'root', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { groups: ['admin'] }
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect(user.type).to.equal('root') // unchanged
|
||||||
|
expect(user.save.called).to.be.false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('no group claim configured', function () {
|
||||||
|
it('should do nothing when authOpenIDGroupClaim is empty', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = ''
|
||||||
|
|
||||||
|
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { groups: ['admin'] }
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect(user.type).to.equal('user') // unchanged
|
||||||
|
expect(user.save.called).to.be.false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('missing group claim in userinfo', function () {
|
||||||
|
it('should throw when group claim is not in userinfo', async function () {
|
||||||
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||||
|
|
||||||
|
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||||
|
const userinfo = { email: 'test@example.com' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await strategy.setUserGroup(user, userinfo)
|
||||||
|
expect.fail('Should have thrown')
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).to.be.instanceOf(AuthError)
|
||||||
|
expect(error.statusCode).to.equal(401)
|
||||||
|
expect(error.message).to.include('Group claim groups not found')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
155
test/server/auth/OidcSettingsSchema.test.js
Normal file
155
test/server/auth/OidcSettingsSchema.test.js
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
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 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
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue