From d6cfdd3becae84cb9676adfabeab2a0120a98f62 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sat, 6 Jun 2026 19:02:17 +0100 Subject: [PATCH] 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. --- client/pages/config/authentication.vue | 14 ++++ client/strings/en-us.json | 2 + server/auth/OidcAuthStrategy.js | 6 +- server/objects/settings/ServerSettings.js | 10 +++ test/server/auth/OidcAuthStrategy.test.js | 78 +++++++++++++++++++++++ 5 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 test/server/auth/OidcAuthStrategy.test.js diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index f31f9ea22..47ccf5d59 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -108,6 +108,20 @@

+
+
+ +
+

+
+ +
+
+ +
+

+
+
diff --git a/client/strings/en-us.json b/client/strings/en-us.json index fb2bcb281..887fae7e0 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -497,9 +497,11 @@ "LabelNumberOfBooks": "Number of Books", "LabelNumberOfChapters": "Number of chapters:", "LabelNumberOfEpisodes": "# of Episodes", + "LabelOpenIDAdminGroupsDescription": "Comma-separated list of group names from the group claim that should be mapped to the admin 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 (if configured). 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 false. 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.", "LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as groups. If configured, 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 (admin, user, guest, or an admin group above). Set to user or guest to admit such users with that role instead of denying access. Leave empty to deny access when no group matches.", "LabelOpenRSSFeed": "Open RSS Feed", "LabelOverwrite": "Overwrite", "LabelPaginationPageXOfY": "Page {0} of {1}", diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js index 64ab82448..8014a436c 100644 --- a/server/auth/OidcAuthStrategy.js +++ b/server/auth/OidcAuthStrategy.js @@ -192,10 +192,12 @@ class OidcAuthStrategy { 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'] - 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 (user.type === 'root') { // Check OpenID Group diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 99f5b76aa..f51efce83 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -82,6 +82,8 @@ class ServerSettings { this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] this.authOpenIDGroupClaim = '' this.authOpenIDAdvancedPermsClaim = '' + this.authOpenIDAdminGroups = '' + this.authOpenIDGroupDefaultRole = null this.authOpenIDSubfolderForRedirectURLs = undefined if (settings) { @@ -146,6 +148,8 @@ class ServerSettings { this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth'] this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || '' this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || '' + this.authOpenIDAdminGroups = settings.authOpenIDAdminGroups || '' + this.authOpenIDGroupDefaultRole = settings.authOpenIDGroupDefaultRole || null this.authOpenIDSubfolderForRedirectURLs = settings.authOpenIDSubfolderForRedirectURLs if (!Array.isArray(this.authActiveAuthMethods)) { @@ -256,6 +260,8 @@ class ServerSettings { authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + authOpenIDAdminGroups: this.authOpenIDAdminGroups, // Do not return to client + authOpenIDGroupDefaultRole: this.authOpenIDGroupDefaultRole, // Do not return to client authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs } } @@ -268,6 +274,8 @@ class ServerSettings { delete json.authOpenIDMobileRedirectURIs delete json.authOpenIDGroupClaim delete json.authOpenIDAdvancedPermsClaim + delete json.authOpenIDAdminGroups + delete json.authOpenIDGroupDefaultRole return json } @@ -302,6 +310,8 @@ class ServerSettings { authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + authOpenIDAdminGroups: this.authOpenIDAdminGroups, // Do not return to client + authOpenIDGroupDefaultRole: this.authOpenIDGroupDefaultRole, // Do not return to client authOpenIDSubfolderForRedirectURLs: this.authOpenIDSubfolderForRedirectURLs, authOpenIDSamplePermissions: User.getSampleAbsPermissions() diff --git a/test/server/auth/OidcAuthStrategy.test.js b/test/server/auth/OidcAuthStrategy.test.js new file mode 100644 index 000000000..e701ec426 --- /dev/null +++ b/test/server/auth/OidcAuthStrategy.test.js @@ -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 + }) + }) +})