diff --git a/test/server/auth/OidcAuthStrategy.test.js b/test/server/auth/OidcAuthStrategy.test.js index 78c72455a..4db7682a2 100644 --- a/test/server/auth/OidcAuthStrategy.test.js +++ b/test/server/auth/OidcAuthStrategy.test.js @@ -2,8 +2,8 @@ const { expect } = require('chai') const sinon = require('sinon') const AuthError = require('../../../server/auth/AuthError') -// Test the real OidcAuthStrategy.setUserGroup method by stubbing its dependencies -describe('OidcAuthStrategy - setUserGroup', function () { +// Test the real OidcAuthStrategy by stubbing its module-level dependencies +describe('OidcAuthStrategy', function () { let OidcAuthStrategy, strategy let DatabaseStub @@ -16,11 +16,19 @@ describe('OidcAuthStrategy - setUserGroup', function () { authOpenIDGroupMap: {}, authOpenIDScopes: 'openid profile email', isOpenIDAuthSettingsValid: false, - authOpenIDMobileRedirectURIs: [] + authOpenIDMobileRedirectURIs: ['audiobookshelf://oauth'], + authOpenIDAutoRegister: false, + authOpenIDRequireVerifiedEmail: false, + authOpenIDAdvancedPermsClaim: '' } + global.RouterBasePath = '/audiobookshelf' DatabaseStub = { - serverSettings: global.ServerSettings + serverSettings: global.ServerSettings, + userModel: { + findUserFromOpenIdUserInfo: sinon.stub(), + createUserFromOpenIdUserInfo: sinon.stub() + } } const LoggerStub = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), debug: sinon.stub() } @@ -54,185 +62,515 @@ describe('OidcAuthStrategy - setUserGroup', function () { else delete require.cache[loggerPath] delete require.cache[require.resolve('../../../server/auth/OidcAuthStrategy')] - - global.ServerSettings.authOpenIDGroupClaim = '' - global.ServerSettings.authOpenIDGroupMap = {} + delete global.RouterBasePath sinon.restore() }) - describe('legacy direct name match (empty groupMap)', function () { - it('should assign admin role when group list includes admin', async function () { - global.ServerSettings.authOpenIDGroupClaim = 'groups' - DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' - global.ServerSettings.authOpenIDGroupMap = {} + // ── setUserGroup ───────────────────────────────────────────────────── - const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } - const userinfo = { groups: ['Admin', 'Users'] } + describe('setUserGroup', function () { + describe('legacy direct name match (empty groupMap)', function () { + it('should assign admin role when group list includes admin', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} - await strategy.setUserGroup(user, userinfo) - expect(user.type).to.equal('admin') - expect(user.save.calledOnce).to.be.true + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['Admin', 'Users'] }) + 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 () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['User', 'Guests'] }) + expect(user.type).to.equal('user') + }) + + it('should throw when no valid group found', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + try { + await strategy.setUserGroup(user, { groups: ['unknown-group'] }) + 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') + } + }) }) - it('should assign user role when group list includes user but not admin', async function () { - global.ServerSettings.authOpenIDGroupClaim = 'groups' - DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' - global.ServerSettings.authOpenIDGroupMap = {} + describe('explicit group mapping', function () { + it('should map custom group names to roles', async function () { + DatabaseStub.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: ['User', 'Guests'] } + const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['oidc-users'] }) + expect(user.type).to.equal('user') + }) - await strategy.setUserGroup(user, userinfo) - expect(user.type).to.equal('user') + it('should prioritize admin over user', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = { 'team-leads': 'admin', 'developers': 'user' } + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['developers', 'team-leads'] }) + expect(user.type).to.equal('admin') + }) + + it('should be case-insensitive for group matching', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = { 'MyAdmins': 'admin' } + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['myadmins'] }) + expect(user.type).to.equal('admin') + }) + + it('should throw when no mapped group matches', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = { 'admins': 'admin' } + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + try { + await strategy.setUserGroup(user, { groups: ['random-group'] }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.statusCode).to.equal(401) + } + }) }) - it('should throw when no valid group found', async function () { - global.ServerSettings.authOpenIDGroupClaim = 'groups' - DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' - global.ServerSettings.authOpenIDGroupMap = {} + describe('root user protection', function () { + it('should not downgrade root user to non-admin', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} - const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } - const userinfo = { groups: ['unknown-group'] } + const user = { type: 'root', username: 'root', save: sinon.stub().resolves() } + try { + await strategy.setUserGroup(user, { groups: ['user'] }) + 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') + } + }) - 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') - } + it('should allow root user with admin group (no change)', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + + const user = { type: 'root', username: 'root', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['admin'] }) + expect(user.type).to.equal('root') + expect(user.save.called).to.be.false + }) + }) + + describe('no group claim configured', function () { + it('should do nothing when authOpenIDGroupClaim is empty', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = '' + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + await strategy.setUserGroup(user, { groups: ['admin'] }) + expect(user.type).to.equal('user') + 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 () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + + const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } + try { + await strategy.setUserGroup(user, { email: 'test@example.com' }) + 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') + } + }) }) }) - describe('explicit group mapping', function () { - it('should map custom group names to roles', async function () { - global.ServerSettings.authOpenIDGroupClaim = 'groups' - DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' - global.ServerSettings.authOpenIDGroupMap = { - 'oidc-admins': 'admin', - 'oidc-users': 'user', - 'oidc-guests': 'guest' - } + // ── validateGroupClaim ─────────────────────────────────────────────── - 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' - DatabaseStub.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' - DatabaseStub.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' - DatabaseStub.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' - DatabaseStub.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' - DatabaseStub.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 = '' + describe('validateGroupClaim', function () { + it('should return true when no group claim is configured', function () { DatabaseStub.serverSettings.authOpenIDGroupClaim = '' + expect(strategy.validateGroupClaim({ groups: ['admin'] })).to.be.true + expect(strategy.validateGroupClaim({})).to.be.true + }) - const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } - const userinfo = { groups: ['admin'] } + it('should return true when group claim exists in userinfo', function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + expect(strategy.validateGroupClaim({ groups: ['admin'] })).to.be.true + }) - await strategy.setUserGroup(user, userinfo) - expect(user.type).to.equal('user') // unchanged - expect(user.save.called).to.be.false + it('should return false when group claim is missing from userinfo', function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + expect(strategy.validateGroupClaim({ email: 'test@example.com' })).to.be.false + }) + + it('should return false when group claim is empty array', function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + // Empty array is falsy for the `!userinfo[groupClaimName]` check? No, [] is truthy. + // Actually [] is truthy in JS, so this should return true + expect(strategy.validateGroupClaim({ groups: [] })).to.be.true + }) + + it('should return false when group claim is null', function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + expect(strategy.validateGroupClaim({ groups: null })).to.be.false + }) + + it('should work with custom claim names', function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'urn:zitadel:iam:org:project:roles' + expect(strategy.validateGroupClaim({ 'urn:zitadel:iam:org:project:roles': ['admin'] })).to.be.true + expect(strategy.validateGroupClaim({ groups: ['admin'] })).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' - DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + // ── isValidRedirectUri ─────────────────────────────────────────────── - const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() } - const userinfo = { email: 'test@example.com' } + describe('isValidRedirectUri', function () { + it('should accept URIs in the whitelist', function () { + DatabaseStub.serverSettings.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth', 'myapp://callback'] + expect(strategy.isValidRedirectUri('audiobookshelf://oauth')).to.be.true + expect(strategy.isValidRedirectUri('myapp://callback')).to.be.true + }) + + it('should reject URIs not in the whitelist', function () { + DatabaseStub.serverSettings.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] + expect(strategy.isValidRedirectUri('evil://callback')).to.be.false + expect(strategy.isValidRedirectUri('audiobookshelf://other')).to.be.false + }) + + it('should reject empty string', function () { + DatabaseStub.serverSettings.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] + expect(strategy.isValidRedirectUri('')).to.be.false + }) + + it('should handle empty whitelist', function () { + DatabaseStub.serverSettings.authOpenIDMobileRedirectURIs = [] + expect(strategy.isValidRedirectUri('audiobookshelf://oauth')).to.be.false + }) + + it('should require exact match (no partial matching)', function () { + DatabaseStub.serverSettings.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] + expect(strategy.isValidRedirectUri('audiobookshelf://oauth/extra')).to.be.false + expect(strategy.isValidRedirectUri('audiobookshelf://oaut')).to.be.false + }) + }) + + // ── isValidWebCallbackUrl ──────────────────────────────────────────── + + describe('isValidWebCallbackUrl', function () { + function makeReq(host, secure, xfp) { + return { + secure: !!secure, + get: (header) => { + if (header === 'host') return host + if (header === 'x-forwarded-proto') return xfp || '' + return '' + } + } + } + + it('should accept relative URL starting with router base path', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com') + expect(strategy.isValidWebCallbackUrl('/audiobookshelf/login', req)).to.be.true + expect(strategy.isValidWebCallbackUrl('/audiobookshelf/', req)).to.be.true + }) + + it('should reject relative URL outside router base path', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com') + expect(strategy.isValidWebCallbackUrl('/evil/path', req)).to.be.false + expect(strategy.isValidWebCallbackUrl('/audiobookshel/typo', req)).to.be.false + }) + + it('should accept same-origin absolute URL with matching path', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com:3333', false) + expect(strategy.isValidWebCallbackUrl('http://example.com:3333/audiobookshelf/login', req)).to.be.true + }) + + it('should reject absolute URL with different host', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com', false) + expect(strategy.isValidWebCallbackUrl('http://evil.com/audiobookshelf/login', req)).to.be.false + }) + + it('should reject absolute URL with different protocol', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com', true) + expect(strategy.isValidWebCallbackUrl('http://example.com/audiobookshelf/login', req)).to.be.false + }) + + it('should accept https URL when behind reverse proxy (x-forwarded-proto)', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com', false, 'https') + expect(strategy.isValidWebCallbackUrl('https://example.com/audiobookshelf/login', req)).to.be.true + }) + + it('should handle multiple x-forwarded-proto values', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com', false, 'https, http') + expect(strategy.isValidWebCallbackUrl('https://example.com/audiobookshelf/login', req)).to.be.true + }) + + it('should reject same-origin URL with path outside router base', function () { + global.RouterBasePath = '/audiobookshelf' + const req = makeReq('example.com', false) + expect(strategy.isValidWebCallbackUrl('http://example.com/evil/path', req)).to.be.false + }) + + it('should reject null or empty callback URL', function () { + const req = makeReq('example.com') + expect(strategy.isValidWebCallbackUrl(null, req)).to.be.false + expect(strategy.isValidWebCallbackUrl('', req)).to.be.false + }) + + it('should reject malformed URLs gracefully', function () { + const req = makeReq('example.com') + expect(strategy.isValidWebCallbackUrl('not-a-valid-url', req)).to.be.false + }) + + it('should work with root router base path', function () { + global.RouterBasePath = '' + const req = makeReq('example.com', false) + expect(strategy.isValidWebCallbackUrl('http://example.com/login', req)).to.be.true + }) + }) + + // ── updateUserPermissions ──────────────────────────────────────────── + + describe('updateUserPermissions', function () { + it('should do nothing when no advanced permissions claim is configured', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = '' + const user = { type: 'user', username: 'testuser', updatePermissionsFromExternalJSON: sinon.stub() } + await strategy.updateUserPermissions(user, { perms: '{}' }) + expect(user.updatePermissionsFromExternalJSON.called).to.be.false + }) + + it('should skip admin users', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = 'abs_perms' + const user = { type: 'admin', username: 'adminuser', updatePermissionsFromExternalJSON: sinon.stub() } + await strategy.updateUserPermissions(user, { abs_perms: { canUpload: true } }) + expect(user.updatePermissionsFromExternalJSON.called).to.be.false + }) + + it('should skip root users', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = 'abs_perms' + const user = { type: 'root', username: 'root', updatePermissionsFromExternalJSON: sinon.stub() } + await strategy.updateUserPermissions(user, { abs_perms: { canUpload: true } }) + expect(user.updatePermissionsFromExternalJSON.called).to.be.false + }) + + it('should update permissions for non-admin user', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = 'abs_perms' + const permsData = { canUpload: true, canDelete: false } + const user = { type: 'user', username: 'testuser', updatePermissionsFromExternalJSON: sinon.stub().resolves(true) } + + await strategy.updateUserPermissions(user, { abs_perms: permsData }) + expect(user.updatePermissionsFromExternalJSON.calledOnce).to.be.true + expect(user.updatePermissionsFromExternalJSON.firstCall.args[0]).to.deep.equal(permsData) + }) + + it('should throw when claim is configured but missing from userinfo', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = 'abs_perms' + const user = { type: 'user', username: 'testuser', updatePermissionsFromExternalJSON: sinon.stub() } try { - await strategy.setUserGroup(user, userinfo) + await strategy.updateUserPermissions(user, { email: 'test@example.com' }) 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') + expect(error.message).to.include('abs_perms') + } + }) + + it('should work for guest users', async function () { + DatabaseStub.serverSettings.authOpenIDAdvancedPermsClaim = 'abs_perms' + const user = { type: 'guest', username: 'guestuser', updatePermissionsFromExternalJSON: sinon.stub().resolves(false) } + + await strategy.updateUserPermissions(user, { abs_perms: { canUpload: false } }) + expect(user.updatePermissionsFromExternalJSON.calledOnce).to.be.true + }) + }) + + // ── verifyUser ─────────────────────────────────────────────────────── + + describe('verifyUser', function () { + function makeUser(overrides = {}) { + return { + id: 'user-123', + username: 'testuser', + type: 'user', + isActive: true, + save: sinon.stub().resolves(), + destroy: sinon.stub().resolves(), + updatePermissionsFromExternalJSON: sinon.stub().resolves(false), + ...overrides + } + } + + it('should return existing user on successful verification', async function () { + const existingUser = makeUser() + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(existingUser) + + const tokenset = { id_token: 'test-id-token' } + const userinfo = { sub: 'oidc-sub-123', email: 'test@example.com' } + + const result = await strategy.verifyUser(tokenset, userinfo) + expect(result).to.equal(existingUser) + expect(result.openid_id_token).to.equal('test-id-token') + expect(DatabaseStub.userModel.findUserFromOpenIdUserInfo.calledOnce).to.be.true + }) + + it('should throw when userinfo has no sub', async function () { + try { + await strategy.verifyUser({ id_token: 'tok' }, { email: 'test@example.com' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('no sub') + } + }) + + it('should throw when group claim validation fails', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1', email: 'test@example.com' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('Group claim') + } + }) + + it('should throw when email_verified is false and enforcement is on', async function () { + global.ServerSettings.authOpenIDRequireVerifiedEmail = true + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(makeUser()) + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1', email: 'test@example.com', email_verified: false }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('not verified') + } + }) + + it('should allow login when email_verified is true and enforcement is on', async function () { + global.ServerSettings.authOpenIDRequireVerifiedEmail = true + const user = makeUser() + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(user) + + const result = await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1', email: 'a@b.com', email_verified: true }) + expect(result).to.equal(user) + }) + + it('should allow login when email_verified is missing and enforcement is on', async function () { + // Only reject when explicitly false, not when absent + global.ServerSettings.authOpenIDRequireVerifiedEmail = true + const user = makeUser() + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(user) + + const result = await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1', email: 'a@b.com' }) + expect(result).to.equal(user) + }) + + it('should auto-register new user when enabled', async function () { + global.ServerSettings.authOpenIDAutoRegister = true + const newUser = makeUser({ username: 'newuser' }) + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(null) + DatabaseStub.userModel.createUserFromOpenIdUserInfo.resolves(newUser) + + const result = await strategy.verifyUser({ id_token: 'tok' }, { sub: 'new-sub', email: 'new@example.com' }) + expect(result).to.equal(newUser) + expect(DatabaseStub.userModel.createUserFromOpenIdUserInfo.calledOnce).to.be.true + }) + + it('should throw when user not found and auto-register is disabled', async function () { + global.ServerSettings.authOpenIDAutoRegister = false + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(null) + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'unknown-sub' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('auto-register is disabled') + } + }) + + it('should throw when user is inactive', async function () { + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(makeUser({ isActive: false })) + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('not active') + } + }) + + it('should throw when findUserFromOpenIdUserInfo returns error object', async function () { + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves({ error: 'already linked' }) + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1' }) + expect.fail('Should have thrown') + } catch (error) { + expect(error).to.be.instanceOf(AuthError) + expect(error.message).to.include('already linked') + } + }) + + it('should destroy new user if setUserGroup fails', async function () { + global.ServerSettings.authOpenIDAutoRegister = true + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + const newUser = makeUser({ username: 'newuser' }) + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(null) + DatabaseStub.userModel.createUserFromOpenIdUserInfo.resolves(newUser) + + try { + // groups claim present but no valid role found + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'new-sub', groups: ['unknown-group'] }) + expect.fail('Should have thrown') + } catch (error) { + expect(newUser.destroy.calledOnce).to.be.true + } + }) + + it('should not destroy existing user on error', async function () { + DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups' + global.ServerSettings.authOpenIDGroupMap = {} + const existingUser = makeUser() + DatabaseStub.userModel.findUserFromOpenIdUserInfo.resolves(existingUser) + + try { + await strategy.verifyUser({ id_token: 'tok' }, { sub: 'sub-1', groups: ['unknown-group'] }) + expect.fail('Should have thrown') + } catch (error) { + expect(existingUser.destroy.called).to.be.false } }) })