mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-01 13:39:41 +00:00
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
239 lines
8.8 KiB
JavaScript
239 lines
8.8 KiB
JavaScript
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 () {
|
|
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')]
|
|
|
|
global.ServerSettings = {
|
|
authOpenIDGroupClaim: '',
|
|
authOpenIDGroupMap: {},
|
|
authOpenIDScopes: 'openid profile email',
|
|
isOpenIDAuthSettingsValid: false,
|
|
authOpenIDMobileRedirectURIs: []
|
|
}
|
|
|
|
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() }
|
|
const userinfo = { groups: ['Admin', 'Users'] }
|
|
|
|
await strategy.setUserGroup(user, userinfo)
|
|
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 () {
|
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
|
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
|
global.ServerSettings.authOpenIDGroupMap = {}
|
|
|
|
const user = { type: 'guest', username: 'testuser', save: sinon.stub().resolves() }
|
|
const userinfo = { groups: ['User', 'Guests'] }
|
|
|
|
await strategy.setUserGroup(user, userinfo)
|
|
expect(user.type).to.equal('user')
|
|
})
|
|
|
|
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() }
|
|
const userinfo = { groups: ['unknown-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)
|
|
expect(error.message).to.include('No valid group 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'
|
|
}
|
|
|
|
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 = ''
|
|
DatabaseStub.serverSettings.authOpenIDGroupClaim = ''
|
|
|
|
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
|
const userinfo = { groups: ['admin'] }
|
|
|
|
await strategy.setUserGroup(user, userinfo)
|
|
expect(user.type).to.equal('user') // unchanged
|
|
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 () {
|
|
global.ServerSettings.authOpenIDGroupClaim = 'groups'
|
|
DatabaseStub.serverSettings.authOpenIDGroupClaim = 'groups'
|
|
|
|
const user = { type: 'user', username: 'testuser', save: sinon.stub().resolves() }
|
|
const userinfo = { email: 'test@example.com' }
|
|
|
|
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('Group claim groups not found')
|
|
}
|
|
})
|
|
})
|
|
})
|