This commit is contained in:
Denis Arnst 2026-05-05 20:09:33 -07:00 committed by GitHub
commit ffc5ea37ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 3370 additions and 580 deletions

View file

@ -0,0 +1,24 @@
const { expect } = require('chai')
const AuthError = require('../../../server/auth/AuthError')
describe('AuthError', function () {
it('should create error with default statusCode 500', function () {
const error = new AuthError('Something went wrong')
expect(error.message).to.equal('Something went wrong')
expect(error.statusCode).to.equal(500)
expect(error.name).to.equal('AuthError')
expect(error).to.be.instanceOf(Error)
})
it('should create error with custom statusCode', function () {
const error = new AuthError('Unauthorized', 401)
expect(error.message).to.equal('Unauthorized')
expect(error.statusCode).to.equal(401)
})
it('should have a stack trace', function () {
const error = new AuthError('test')
expect(error.stack).to.be.a('string')
expect(error.stack).to.include('AuthError')
})
})

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,173 @@
const { expect } = require('chai')
const { validateSettings } = require('../../../server/auth/OidcSettingsSchema')
describe('OidcSettingsSchema - validateSettings', function () {
const validSettings = {
authOpenIDIssuerURL: 'https://auth.example.com',
authOpenIDAuthorizationURL: 'https://auth.example.com/authorize',
authOpenIDTokenURL: 'https://auth.example.com/token',
authOpenIDUserInfoURL: 'https://auth.example.com/userinfo',
authOpenIDJwksURL: 'https://auth.example.com/jwks',
authOpenIDClientID: 'my-client-id',
authOpenIDClientSecret: 'my-client-secret',
authOpenIDTokenSigningAlgorithm: 'RS256'
}
it('should pass with valid required settings', function () {
const result = validateSettings(validSettings)
expect(result.valid).to.be.true
})
it('should fail when required fields are missing', function () {
const result = validateSettings({})
expect(result.valid).to.be.false
expect(result.errors).to.include('Issuer URL is required')
expect(result.errors).to.include('Client ID is required')
expect(result.errors).to.include('Client Secret is required')
})
it('should fail with invalid URL', function () {
const result = validateSettings({
...validSettings,
authOpenIDIssuerURL: 'not-a-url'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Issuer URL: Invalid URL')
})
it('should pass with valid optional fields', function () {
const result = validateSettings({
...validSettings,
authOpenIDLogoutURL: 'https://auth.example.com/logout',
authOpenIDButtonText: 'Login with SSO',
authOpenIDAutoLaunch: false,
authOpenIDAutoRegister: true,
authOpenIDScopes: 'openid profile email groups',
authOpenIDGroupClaim: 'groups'
})
expect(result.valid).to.be.true
})
it('should fail with invalid boolean type', function () {
const result = validateSettings({
...validSettings,
authOpenIDAutoLaunch: 'yes'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Auto Launch: Expected boolean')
})
it('should fail with invalid claim name', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupClaim: '123invalid'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Group Claim: Invalid claim name')
})
it('should pass with valid claim name', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupClaim: 'my-groups_claim'
})
expect(result.valid).to.be.true
})
it('should pass with URN-style claim name (e.g. ZITADEL)', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupClaim: 'urn:zitadel:iam:org:project:roles'
})
expect(result.valid).to.be.true
})
it('should fail with invalid group map values', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupMap: { 'my-group': 'superadmin' }
})
expect(result.valid).to.be.false
expect(result.errors[0]).to.include('Invalid value "superadmin"')
})
it('should pass with valid group map', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupMap: { 'oidc-admins': 'admin', 'oidc-users': 'user', 'oidc-guests': 'guest' }
})
expect(result.valid).to.be.true
})
it('should fail with non-object group map', function () {
const result = validateSettings({
...validSettings,
authOpenIDGroupMap: 'not-an-object'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Group Mapping: Expected object')
})
it('should fail with invalid mobile redirect URIs', function () {
const result = validateSettings({
...validSettings,
authOpenIDMobileRedirectURIs: 'not-an-array'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Mobile Redirect URIs: Expected array')
})
it('should pass with valid redirect URI', function () {
const result = validateSettings({
...validSettings,
authOpenIDMobileRedirectURIs: ['audiobookshelf://oauth']
})
expect(result.valid).to.be.true
})
it('should fail with wildcard URI', function () {
const result = validateSettings({
...validSettings,
authOpenIDMobileRedirectURIs: ['*']
})
expect(result.valid).to.be.false
expect(result.errors[0]).to.include('Invalid URI')
})
it('should not hang on pathological URI input', function () {
this.timeout(1000)
const result = validateSettings({
...validSettings,
authOpenIDMobileRedirectURIs: ['a://-/' + '/'.repeat(100) + '!']
})
expect(result.valid).to.be.false
expect(result.errors[0]).to.include('Invalid URI')
})
it('should accept URI with path segments', function () {
const result = validateSettings({
...validSettings,
authOpenIDMobileRedirectURIs: ['https://example.com/path/to/callback']
})
expect(result.valid).to.be.true
})
it('should reject unknown keys', function () {
const result = validateSettings({
...validSettings,
unknownSetting: 'value'
})
expect(result.valid).to.be.false
expect(result.errors).to.include('Unknown setting: "unknownSetting"')
})
it('should skip validation for empty optional fields', function () {
const result = validateSettings({
...validSettings,
authOpenIDLogoutURL: '',
authOpenIDGroupClaim: '',
authOpenIDGroupMap: {}
})
expect(result.valid).to.be.true
})
})