mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-07 18:01:42 +00:00
Add configurable OIDC group-to-role mapping
Currently OIDC group-based role assignment only recognizes groups named 'admin', 'user', or 'guest' (case-insensitive), and denies login when a user's groups match none of them. This makes group-based roles unusable with identity providers whose group names differ, and offers no way to admit such users with a default role. Add two optional server settings: - authOpenIDAdminGroups: comma-separated group names mapped to the admin role, in addition to the built-in names. - authOpenIDGroupDefaultRole: role assigned when no group matches, instead of denying login. Both default to empty, preserving current behavior. Includes the auth settings UI fields, en-us strings, and unit tests for setUserGroup.
This commit is contained in:
parent
cbda0360aa
commit
d6cfdd3bec
5 changed files with 108 additions and 2 deletions
|
|
@ -108,6 +108,20 @@
|
||||||
<p class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300" v-html="$strings.LabelOpenIDGroupClaimDescription"></p>
|
<p class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300" v-html="$strings.LabelOpenIDGroupClaimDescription"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row mb-4">
|
||||||
|
<div class="w-44 min-w-44">
|
||||||
|
<ui-text-input-with-label ref="openidAdminGroups" v-model="newAuthSettings.authOpenIDAdminGroups" :disabled="savingSettings" :placeholder="'admin'" :label="'Admin Groups'" />
|
||||||
|
</div>
|
||||||
|
<p class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300" v-html="$strings.LabelOpenIDAdminGroupsDescription"></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="openidGroupDefaultRole" v-model="newAuthSettings.authOpenIDGroupDefaultRole" :disabled="savingSettings" :placeholder="'user'" :label="'Default Group Role'" />
|
||||||
|
</div>
|
||||||
|
<p class="sm:pl-4 pt-2 sm:pt-0 text-sm text-gray-300" v-html="$strings.LabelOpenIDGroupDefaultRoleDescription"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col sm:flex-row mb-4">
|
<div class="flex flex-col sm:flex-row mb-4">
|
||||||
<div class="w-44 min-w-44">
|
<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'" />
|
<ui-text-input-with-label ref="openidAdvancedPermsClaim" v-model="newAuthSettings.authOpenIDAdvancedPermsClaim" :disabled="savingSettings" :placeholder="'abspermissions'" :label="'Advanced Permission Claim'" />
|
||||||
|
|
|
||||||
|
|
@ -497,9 +497,11 @@
|
||||||
"LabelNumberOfBooks": "Number of Books",
|
"LabelNumberOfBooks": "Number of Books",
|
||||||
"LabelNumberOfChapters": "Number of chapters:",
|
"LabelNumberOfChapters": "Number of chapters:",
|
||||||
"LabelNumberOfEpisodes": "# of Episodes",
|
"LabelNumberOfEpisodes": "# of Episodes",
|
||||||
|
"LabelOpenIDAdminGroupsDescription": "Comma-separated list of group names from the group claim that should be mapped to the <code>admin</code> role, in addition to the built-in case-insensitive 'admin', 'user', and 'guest' names. Use this when your identity provider's groups are not named to match the application's roles.",
|
||||||
"LabelOpenIDAdvancedPermsClaimDescription": "Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (<b>if configured</b>). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as <code>false</code>. Ensure the identity provider's claim matches the expected structure:",
|
"LabelOpenIDAdvancedPermsClaimDescription": "Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (<b>if configured</b>). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as <code>false</code>. Ensure the identity provider's claim matches the expected structure:",
|
||||||
"LabelOpenIDClaims": "Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.",
|
"LabelOpenIDClaims": "Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.",
|
||||||
"LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as <code>groups</code>. <b>If configured</b>, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.",
|
"LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as <code>groups</code>. <b>If configured</b>, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.",
|
||||||
|
"LabelOpenIDGroupDefaultRoleDescription": "Role to assign when none of the user's groups match a known role (<code>admin</code>, <code>user</code>, <code>guest</code>, or an admin group above). Set to <code>user</code> or <code>guest</code> to admit such users with that role instead of denying access. Leave empty to deny access when no group matches.",
|
||||||
"LabelOpenRSSFeed": "Open RSS Feed",
|
"LabelOpenRSSFeed": "Open RSS Feed",
|
||||||
"LabelOverwrite": "Overwrite",
|
"LabelOverwrite": "Overwrite",
|
||||||
"LabelPaginationPageXOfY": "Page {0} of {1}",
|
"LabelPaginationPageXOfY": "Page {0} of {1}",
|
||||||
|
|
|
||||||
|
|
@ -192,10 +192,12 @@ class OidcAuthStrategy {
|
||||||
|
|
||||||
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
||||||
|
|
||||||
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
const adminGroups = (Database.serverSettings.authOpenIDAdminGroups || '').split(',').map((group) => group.trim().toLowerCase()).filter(Boolean)
|
||||||
|
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()).map((group) => (adminGroups.includes(group) ? 'admin' : group))
|
||||||
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||||
|
|
||||||
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
const defaultRole = (Database.serverSettings.authOpenIDGroupDefaultRole || '').toLowerCase()
|
||||||
|
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) || (rolesInOrderOfPriority.includes(defaultRole) ? defaultRole : undefined)
|
||||||
if (userType) {
|
if (userType) {
|
||||||
if (user.type === 'root') {
|
if (user.type === 'root') {
|
||||||
// Check OpenID Group
|
// Check OpenID Group
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,8 @@ class ServerSettings {
|
||||||
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth']
|
||||||
this.authOpenIDGroupClaim = ''
|
this.authOpenIDGroupClaim = ''
|
||||||
this.authOpenIDAdvancedPermsClaim = ''
|
this.authOpenIDAdvancedPermsClaim = ''
|
||||||
|
this.authOpenIDAdminGroups = ''
|
||||||
|
this.authOpenIDGroupDefaultRole = null
|
||||||
this.authOpenIDSubfolderForRedirectURLs = undefined
|
this.authOpenIDSubfolderForRedirectURLs = undefined
|
||||||
|
|
||||||
if (settings) {
|
if (settings) {
|
||||||
|
|
@ -146,6 +148,8 @@ class ServerSettings {
|
||||||
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth']
|
||||||
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || ''
|
||||||
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || ''
|
||||||
|
this.authOpenIDAdminGroups = settings.authOpenIDAdminGroups || ''
|
||||||
|
this.authOpenIDGroupDefaultRole = settings.authOpenIDGroupDefaultRole || null
|
||||||
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
|
this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs
|
||||||
|
|
||||||
if (!Array.isArray(this.authActiveAuthMethods)) {
|
if (!Array.isArray(this.authActiveAuthMethods)) {
|
||||||
|
|
@ -256,6 +260,8 @@ 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
|
||||||
|
authOpenIDAdminGroups: this.authOpenIDAdminGroups, // Do not return to client
|
||||||
|
authOpenIDGroupDefaultRole: this.authOpenIDGroupDefaultRole, // Do not return to client
|
||||||
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs
|
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -268,6 +274,8 @@ class ServerSettings {
|
||||||
delete json.authOpenIDMobileRedirectURIs
|
delete json.authOpenIDMobileRedirectURIs
|
||||||
delete json.authOpenIDGroupClaim
|
delete json.authOpenIDGroupClaim
|
||||||
delete json.authOpenIDAdvancedPermsClaim
|
delete json.authOpenIDAdvancedPermsClaim
|
||||||
|
delete json.authOpenIDAdminGroups
|
||||||
|
delete json.authOpenIDGroupDefaultRole
|
||||||
return json
|
return json
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,6 +310,8 @@ 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
|
||||||
|
authOpenIDAdminGroups: this.authOpenIDAdminGroups, // Do not return to client
|
||||||
|
authOpenIDGroupDefaultRole: this.authOpenIDGroupDefaultRole, // Do not return to client
|
||||||
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
|
authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs,
|
||||||
|
|
||||||
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
|
authOpenIDSamplePermissions: User.getSampleAbsPermissions()
|
||||||
|
|
|
||||||
78
test/server/auth/OidcAuthStrategy.test.js
Normal file
78
test/server/auth/OidcAuthStrategy.test.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
const Logger = require('../../../server/Logger')
|
||||||
|
const OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy')
|
||||||
|
|
||||||
|
describe('OidcAuthStrategy', () => {
|
||||||
|
let strategy
|
||||||
|
let originalServerSettings
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
strategy = new OidcAuthStrategy()
|
||||||
|
originalServerSettings = Database.serverSettings
|
||||||
|
sinon.stub(Logger, 'info')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Database.serverSettings = originalServerSettings
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
const fakeUser = (type) => ({ type, username: 'tester', save: sinon.stub().resolves() })
|
||||||
|
|
||||||
|
describe('setUserGroup', () => {
|
||||||
|
it('maps a configured admin group to the admin role', async () => {
|
||||||
|
Database.serverSettings = { authOpenIDGroupClaim: 'groups', authOpenIDAdminGroups: 'syncloud', authOpenIDGroupDefaultRole: null }
|
||||||
|
const user = fakeUser('user')
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, { groups: ['syncloud'] })
|
||||||
|
|
||||||
|
expect(user.type).to.equal('admin')
|
||||||
|
expect(user.save.calledOnce).to.be.true
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still maps built-in role names when admin groups are configured', async () => {
|
||||||
|
Database.serverSettings = { authOpenIDGroupClaim: 'groups', authOpenIDAdminGroups: 'syncloud', authOpenIDGroupDefaultRole: null }
|
||||||
|
const user = fakeUser('guest')
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, { groups: ['User'] })
|
||||||
|
|
||||||
|
expect(user.type).to.equal('user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default role when no group matches', async () => {
|
||||||
|
Database.serverSettings = { authOpenIDGroupClaim: 'groups', authOpenIDAdminGroups: '', authOpenIDGroupDefaultRole: 'user' }
|
||||||
|
const user = fakeUser('guest')
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, { groups: ['some-unrelated-group'] })
|
||||||
|
|
||||||
|
expect(user.type).to.equal('user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('denies login when no group matches and no default role is set', async () => {
|
||||||
|
Database.serverSettings = { authOpenIDGroupClaim: 'groups', authOpenIDAdminGroups: '', authOpenIDGroupDefaultRole: null }
|
||||||
|
const user = fakeUser('user')
|
||||||
|
|
||||||
|
let error
|
||||||
|
try {
|
||||||
|
await strategy.setUserGroup(user, { groups: ['some-unrelated-group'] })
|
||||||
|
} catch (e) {
|
||||||
|
error = e
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).to.be.an('error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing when no group claim is configured', async () => {
|
||||||
|
Database.serverSettings = { authOpenIDGroupClaim: '' }
|
||||||
|
const user = fakeUser('user')
|
||||||
|
|
||||||
|
await strategy.setUserGroup(user, { groups: ['syncloud'] })
|
||||||
|
|
||||||
|
expect(user.type).to.equal('user')
|
||||||
|
expect(user.save.called).to.be.false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue