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 + }) + }) +})