mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-04 06:59:41 +00:00
Add OIDC Back-Channel Logout support
Implement OIDC Back-Channel Logout 1.0 (RFC). When enabled, the IdP can POST a signed logout_token JWT to invalidate user sessions server-side. - Add BackchannelLogoutHandler: JWT verification via jose, jti replay protection with bounded cache, session destruction by sub or sid - Add oidcSessionId column to sessions table with index for fast lookups - Add backchannel logout route (POST /auth/openid/backchannel-logout) - Notify connected clients via socket to redirect to login page - Add authOpenIDBackchannelLogoutEnabled toggle in schema-driven settings UI - Migration v2.34.0 adds oidcSessionId column and index - Polish settings UI: auto-populate loading state, subfolder dropdown options, KeyValueEditor fixes, localized descriptions via descriptionKey, duplicate key detection, success/error toasts - Localize backchannel logout toast (ToastSessionEndedByProvider) - OidcAuthStrategy tests now use real class via require-cache stubbing
This commit is contained in:
parent
33bee70a12
commit
073eff74ef
16 changed files with 886 additions and 104 deletions
319
test/server/auth/BackchannelLogoutHandler.test.js
Normal file
319
test/server/auth/BackchannelLogoutHandler.test.js
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
const { expect } = require('chai')
|
||||
const sinon = require('sinon')
|
||||
|
||||
describe('BackchannelLogoutHandler', function () {
|
||||
let BackchannelLogoutHandler, handler
|
||||
let joseStub, DatabaseStub, SocketAuthorityStub
|
||||
|
||||
const BACKCHANNEL_EVENT = 'http://schemas.openid.net/event/backchannel-logout'
|
||||
|
||||
beforeEach(function () {
|
||||
// Clear require cache so we get fresh stubs each test
|
||||
delete require.cache[require.resolve('../../../server/auth/BackchannelLogoutHandler')]
|
||||
|
||||
// Stub jose
|
||||
joseStub = {
|
||||
createRemoteJWKSet: sinon.stub().returns('jwks-function'),
|
||||
jwtVerify: sinon.stub()
|
||||
}
|
||||
|
||||
// Stub Database
|
||||
DatabaseStub = {
|
||||
sessionModel: {
|
||||
destroy: sinon.stub().resolves(1)
|
||||
},
|
||||
userModel: {
|
||||
getUserByOpenIDSub: sinon.stub()
|
||||
}
|
||||
}
|
||||
|
||||
// Stub SocketAuthority
|
||||
SocketAuthorityStub = {
|
||||
clientEmitter: sinon.stub()
|
||||
}
|
||||
|
||||
// Set up global.ServerSettings
|
||||
global.ServerSettings = {
|
||||
authOpenIDJwksURL: 'https://idp.example.com/.well-known/jwks.json',
|
||||
authOpenIDIssuerURL: 'https://idp.example.com',
|
||||
authOpenIDClientID: 'my-client-id'
|
||||
}
|
||||
|
||||
// Use proxyquire-style: intercept requires by replacing module cache entries
|
||||
const Module = require('module')
|
||||
const originalResolve = Module._resolveFilename
|
||||
const stubs = {
|
||||
jose: joseStub,
|
||||
'../Logger': { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), debug: sinon.stub() },
|
||||
'../Database': DatabaseStub,
|
||||
'../SocketAuthority': SocketAuthorityStub
|
||||
}
|
||||
|
||||
// Pre-populate the require cache with stubs
|
||||
const path = require('path')
|
||||
const handlerPath = require.resolve('../../../server/auth/BackchannelLogoutHandler')
|
||||
|
||||
// We need to stub the dependencies before requiring the handler
|
||||
// Clear any cached versions of the dependencies
|
||||
const josePath = require.resolve('jose')
|
||||
const loggerPath = require.resolve('../../../server/Logger')
|
||||
const databasePath = require.resolve('../../../server/Database')
|
||||
const socketPath = require.resolve('../../../server/SocketAuthority')
|
||||
|
||||
// Save original modules
|
||||
const originalJose = require.cache[josePath]
|
||||
const originalLogger = require.cache[loggerPath]
|
||||
const originalDatabase = require.cache[databasePath]
|
||||
const originalSocket = require.cache[socketPath]
|
||||
|
||||
// Replace with stubs
|
||||
require.cache[josePath] = { id: josePath, exports: joseStub }
|
||||
require.cache[loggerPath] = { id: loggerPath, exports: stubs['../Logger'] }
|
||||
require.cache[databasePath] = { id: databasePath, exports: DatabaseStub }
|
||||
require.cache[socketPath] = { id: socketPath, exports: SocketAuthorityStub }
|
||||
|
||||
// Now require the handler
|
||||
BackchannelLogoutHandler = require('../../../server/auth/BackchannelLogoutHandler')
|
||||
handler = new BackchannelLogoutHandler()
|
||||
|
||||
// Store originals for cleanup
|
||||
this._originals = { josePath, loggerPath, databasePath, socketPath, originalJose, originalLogger, originalDatabase, originalSocket }
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
// Restore original modules
|
||||
const { josePath, loggerPath, databasePath, socketPath, originalJose, originalLogger, originalDatabase, originalSocket } = this._originals
|
||||
if (originalJose) require.cache[josePath] = originalJose
|
||||
else delete require.cache[josePath]
|
||||
if (originalLogger) require.cache[loggerPath] = originalLogger
|
||||
else delete require.cache[loggerPath]
|
||||
if (originalDatabase) require.cache[databasePath] = originalDatabase
|
||||
else delete require.cache[databasePath]
|
||||
if (originalSocket) require.cache[socketPath] = originalSocket
|
||||
else delete require.cache[socketPath]
|
||||
|
||||
delete require.cache[require.resolve('../../../server/auth/BackchannelLogoutHandler')]
|
||||
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('should destroy all user sessions for sub-only token', async function () {
|
||||
const mockUser = { id: 'user-123', username: 'testuser' }
|
||||
DatabaseStub.userModel.getUserByOpenIDSub.resolves(mockUser)
|
||||
DatabaseStub.sessionModel.destroy.resolves(2)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-1',
|
||||
sub: 'oidc-sub-value',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.firstCall.args[0]).to.deep.equal({ where: { userId: 'user-123' } })
|
||||
expect(SocketAuthorityStub.clientEmitter.calledOnce).to.be.true
|
||||
expect(SocketAuthorityStub.clientEmitter.firstCall.args).to.deep.equal(['user-123', 'backchannel_logout', {}])
|
||||
})
|
||||
|
||||
it('should destroy session by sid for sid-only token', async function () {
|
||||
DatabaseStub.sessionModel.destroy.resolves(1)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-2',
|
||||
sid: 'session-abc',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.firstCall.args[0]).to.deep.equal({ where: { oidcSessionId: 'session-abc' } })
|
||||
// No sub means no user lookup and no socket notification
|
||||
expect(DatabaseStub.userModel.getUserByOpenIDSub.called).to.be.false
|
||||
expect(SocketAuthorityStub.clientEmitter.called).to.be.false
|
||||
})
|
||||
|
||||
it('should destroy by sid and notify by sub when both present', async function () {
|
||||
const mockUser = { id: 'user-456', username: 'testuser2' }
|
||||
DatabaseStub.userModel.getUserByOpenIDSub.resolves(mockUser)
|
||||
DatabaseStub.sessionModel.destroy.resolves(1)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-3',
|
||||
sub: 'oidc-sub-value',
|
||||
sid: 'session-xyz',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.true
|
||||
// Should destroy by sid (first call) and NOT destroy by userId (sid takes priority)
|
||||
expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.firstCall.args[0]).to.deep.equal({ where: { oidcSessionId: 'session-xyz' } })
|
||||
// But should still notify the user
|
||||
expect(SocketAuthorityStub.clientEmitter.calledOnce).to.be.true
|
||||
expect(SocketAuthorityStub.clientEmitter.firstCall.args[0]).to.equal('user-456')
|
||||
})
|
||||
|
||||
it('should return error for invalid JWT signature', async function () {
|
||||
joseStub.jwtVerify.rejects(new Error('JWS signature verification failed'))
|
||||
|
||||
const result = await handler.processLogoutToken('invalid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return error for missing events claim', async function () {
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
sub: 'oidc-sub-value'
|
||||
// no events
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return error for wrong events claim value', async function () {
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
sub: 'oidc-sub-value',
|
||||
events: { 'http://some-other-event': {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return error when token is missing jti claim', async function () {
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
sub: 'oidc-sub-value',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
// no jti
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return error when token contains nonce', async function () {
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-4',
|
||||
sub: 'oidc-sub-value',
|
||||
nonce: 'some-nonce',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return error when token has neither sub nor sid', async function () {
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-5',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.false
|
||||
expect(result.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should return success for unknown sub (no user found)', async function () {
|
||||
DatabaseStub.userModel.getUserByOpenIDSub.resolves(null)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-6',
|
||||
sub: 'unknown-sub',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
// Per spec, unknown sub is not an error
|
||||
expect(result.success).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.called).to.be.false
|
||||
expect(SocketAuthorityStub.clientEmitter.called).to.be.false
|
||||
})
|
||||
|
||||
it('should reject replayed jti', async function () {
|
||||
const mockUser = { id: 'user-123', username: 'testuser' }
|
||||
DatabaseStub.userModel.getUserByOpenIDSub.resolves(mockUser)
|
||||
DatabaseStub.sessionModel.destroy.resolves(1)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'same-jti',
|
||||
sub: 'oidc-sub-value',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
// First call should succeed
|
||||
const result1 = await handler.processLogoutToken('valid.jwt.token')
|
||||
expect(result1.success).to.be.true
|
||||
|
||||
// Second call with same jti should be rejected
|
||||
const result2 = await handler.processLogoutToken('valid.jwt.token')
|
||||
expect(result2.success).to.be.false
|
||||
expect(result2.error).to.equal('invalid_request')
|
||||
})
|
||||
|
||||
it('should warn when sid destroy matches 0 sessions', async function () {
|
||||
DatabaseStub.sessionModel.destroy.resolves(0)
|
||||
|
||||
joseStub.jwtVerify.resolves({
|
||||
payload: {
|
||||
jti: 'unique-id-warn',
|
||||
sid: 'old-session-id',
|
||||
events: { [BACKCHANNEL_EVENT]: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handler.processLogoutToken('valid.jwt.token')
|
||||
|
||||
expect(result.success).to.be.true
|
||||
expect(DatabaseStub.sessionModel.destroy.calledOnce).to.be.true
|
||||
})
|
||||
|
||||
it('should reset cached JWKS and jti cache', function () {
|
||||
// Call _getJwks to cache
|
||||
handler._getJwks()
|
||||
expect(joseStub.createRemoteJWKSet.calledOnce).to.be.true
|
||||
|
||||
// Reset and call again
|
||||
handler.reset()
|
||||
handler._getJwks()
|
||||
expect(joseStub.createRemoteJWKSet.calledTwice).to.be.true
|
||||
})
|
||||
})
|
||||
|
|
@ -2,13 +2,15 @@ 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
|
||||
// Test the real OidcAuthStrategy.setUserGroup method by stubbing its dependencies
|
||||
describe('OidcAuthStrategy - setUserGroup', function () {
|
||||
let OidcAuthStrategy, strategy
|
||||
let DatabaseStub
|
||||
|
||||
beforeEach(function () {
|
||||
// Clear require cache so we get fresh stubs each test
|
||||
delete require.cache[require.resolve('../../../server/auth/OidcAuthStrategy')]
|
||||
|
||||
before(function () {
|
||||
// Stub global dependencies that OidcAuthStrategy requires at import time
|
||||
global.ServerSettings = {
|
||||
authOpenIDGroupClaim: '',
|
||||
authOpenIDGroupMap: {},
|
||||
|
|
@ -16,71 +18,52 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
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)
|
||||
}
|
||||
}
|
||||
DatabaseStub = {
|
||||
serverSettings: global.ServerSettings
|
||||
}
|
||||
|
||||
const LoggerStub = { info: sinon.stub(), warn: sinon.stub(), error: sinon.stub(), debug: sinon.stub() }
|
||||
|
||||
// Stub dependencies in require cache
|
||||
const databasePath = require.resolve('../../../server/Database')
|
||||
const loggerPath = require.resolve('../../../server/Logger')
|
||||
|
||||
// Save originals for cleanup
|
||||
this._originals = {
|
||||
databasePath,
|
||||
loggerPath,
|
||||
originalDatabase: require.cache[databasePath],
|
||||
originalLogger: require.cache[loggerPath]
|
||||
}
|
||||
|
||||
// Replace with stubs
|
||||
require.cache[databasePath] = { id: databasePath, exports: DatabaseStub }
|
||||
require.cache[loggerPath] = { id: loggerPath, exports: LoggerStub }
|
||||
|
||||
// Now require the real class
|
||||
OidcAuthStrategy = require('../../../server/auth/OidcAuthStrategy')
|
||||
strategy = new OidcAuthStrategy()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
const { databasePath, loggerPath, originalDatabase, originalLogger } = this._originals
|
||||
if (originalDatabase) require.cache[databasePath] = originalDatabase
|
||||
else delete require.cache[databasePath]
|
||||
if (originalLogger) require.cache[loggerPath] = originalLogger
|
||||
else delete require.cache[loggerPath]
|
||||
|
||||
delete require.cache[require.resolve('../../../server/auth/OidcAuthStrategy')]
|
||||
|
||||
global.ServerSettings.authOpenIDGroupClaim = ''
|
||||
global.ServerSettings.authOpenIDGroupMap = {}
|
||||
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 = {}
|
||||
|
||||
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||
|
|
@ -93,6 +76,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
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 = {}
|
||||
|
||||
const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() }
|
||||
|
|
@ -104,6 +88,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
it('should throw when no valid group found', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {}
|
||||
|
||||
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||
|
|
@ -123,6 +108,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
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',
|
||||
|
|
@ -138,6 +124,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
it('should prioritize admin over user', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {
|
||||
'team-leads': 'admin',
|
||||
'developers': 'user'
|
||||
|
|
@ -152,6 +139,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
it('should be case-insensitive for group matching', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {
|
||||
'MyAdmins': 'admin'
|
||||
}
|
||||
|
|
@ -165,6 +153,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
it('should throw when no mapped group matches', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
||||
global.ServerSettings.authOpenIDGroupMap = {
|
||||
'admins': 'admin'
|
||||
}
|
||||
|
|
@ -185,6 +174,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
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() }
|
||||
|
|
@ -202,6 +192,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
|
||||
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() }
|
||||
|
|
@ -216,6 +207,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
describe('no group claim configured', function () {
|
||||
it('should do nothing when authOpenIDGroupClaim is empty', async function () {
|
||||
global.ServerSettings.authOpenIDGroupClaim = ''
|
||||
DatabaseStub.serverSettings.authOpenIDGroupClaim = ''
|
||||
|
||||
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||
const userinfo = { groups: ['admin'] }
|
||||
|
|
@ -229,6 +221,7 @@ describe('OidcAuthStrategy - setUserGroup', function () {
|
|||
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'
|
||||
|
||||
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
||||
const userinfo = { email: 'test@example.com' }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue