mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-26 22:48:49 +02:00
143 lines
5.1 KiB
JavaScript
143 lines
5.1 KiB
JavaScript
const { expect } = require('chai')
|
|
const sinon = require('sinon')
|
|
const { Op } = require('sequelize')
|
|
|
|
const Database = require('../../../server/Database')
|
|
const jwt = require('../../../server/libs/jsonwebtoken')
|
|
|
|
// Database → Auth → TokenManager circular require can leave TokenManager with a partial Database reference; reload before each test
|
|
function loadTokenManager() {
|
|
delete require.cache[require.resolve('../../../server/auth/TokenManager')]
|
|
return require('../../../server/auth/TokenManager')
|
|
}
|
|
|
|
describe('TokenManager', () => {
|
|
const secret = 'test-jwt-secret'
|
|
const userId = 'user-uuid-1'
|
|
let TokenManager
|
|
let tokenManager
|
|
|
|
beforeEach(() => {
|
|
TokenManager = loadTokenManager()
|
|
TokenManager.TokenSecret = secret
|
|
tokenManager = new TokenManager()
|
|
})
|
|
|
|
afterEach(() => {
|
|
sinon.restore()
|
|
})
|
|
|
|
describe('validateAccessToken', () => {
|
|
it('rejects refresh tokens', () => {
|
|
const refreshToken = jwt.sign({ userId, type: 'refresh' }, secret, { expiresIn: 3600 })
|
|
expect(TokenManager.validateAccessToken(refreshToken)).to.equal(null)
|
|
})
|
|
|
|
it('accepts access tokens', () => {
|
|
const accessToken = jwt.sign({ userId, type: 'access' }, secret, { expiresIn: 3600 })
|
|
const decoded = TokenManager.validateAccessToken(accessToken)
|
|
expect(decoded.userId).to.equal(userId)
|
|
expect(decoded.type).to.equal('access')
|
|
})
|
|
})
|
|
|
|
describe('jwtAuthCheck', () => {
|
|
const user = { id: userId, username: 'testuser', isActive: true }
|
|
|
|
it('rejects refresh tokens for API auth', async () => {
|
|
const refreshToken = tokenManager.generateRefreshToken(user)
|
|
const decoded = jwt.verify(refreshToken, secret)
|
|
const done = sinon.spy()
|
|
|
|
await tokenManager.jwtAuthCheck(decoded, done)
|
|
|
|
expect(done.calledWith(null, null)).to.be.true
|
|
})
|
|
|
|
it('allows access tokens for active users', async () => {
|
|
sinon.stub(Database, 'userModel').get(() => ({
|
|
getUserByIdOrOldId: sinon.stub().resolves(user)
|
|
}))
|
|
const decoded = jwt.verify(tokenManager.generateTempAccessToken(user), secret)
|
|
const done = sinon.spy()
|
|
|
|
await tokenManager.jwtAuthCheck(decoded, done)
|
|
|
|
expect(done.calledWith(null, user)).to.be.true
|
|
})
|
|
})
|
|
|
|
describe('invalidateJwtSessionsForUser', () => {
|
|
const targetUser = { id: userId, username: 'testuser' }
|
|
const currentSession = {
|
|
id: 'session-current',
|
|
userId,
|
|
refreshToken: 'refresh-current'
|
|
}
|
|
|
|
/** Minimal req/res for session invalidation (ApiRouter provides auth on real requests). */
|
|
function makeReq({ requestUserId = userId, refreshToken = null, cookieRefreshToken = null } = {}) {
|
|
return {
|
|
user: { id: requestUserId },
|
|
cookies: cookieRefreshToken ? { refresh_token: cookieRefreshToken } : {},
|
|
headers: refreshToken ? { 'x-refresh-token': refreshToken } : {}
|
|
}
|
|
}
|
|
|
|
let sessionFindOne
|
|
let sessionDestroy
|
|
let rotateStub
|
|
|
|
beforeEach(() => {
|
|
sessionFindOne = sinon.stub().resolves(currentSession)
|
|
sessionDestroy = sinon.stub().resolves(1)
|
|
sinon.stub(Database, 'sessionModel').get(() => ({
|
|
findOne: sessionFindOne,
|
|
destroy: sessionDestroy
|
|
}))
|
|
rotateStub = sinon.stub(tokenManager, 'rotateTokensForSession').resolves({
|
|
accessToken: 'access-new',
|
|
refreshToken: 'refresh-new'
|
|
})
|
|
})
|
|
|
|
it('self password change: keeps current session, deletes others', async () => {
|
|
const req = makeReq({ refreshToken: 'refresh-current' })
|
|
const res = { cookie: sinon.spy() }
|
|
|
|
const result = await tokenManager.invalidateJwtSessionsForUser(targetUser, req, res)
|
|
|
|
// Found this device's session using the x-refresh-token header
|
|
expect(sessionFindOne.calledOnce).to.be.true
|
|
const findWhere = sessionFindOne.firstCall.args[0].where
|
|
expect(findWhere.userId).to.equal(userId)
|
|
expect(findWhere[Op.or]).to.deep.equal([{ refreshToken: 'refresh-current' }, { lastRefreshToken: 'refresh-current' }])
|
|
|
|
// Rotated in place (no grace period) so the caller keeps a valid session
|
|
expect(rotateStub.calledOnceWith(currentSession, targetUser, req, res, false)).to.be.true
|
|
|
|
// Deleted all other sessions, but not this one
|
|
expect(sessionDestroy.calledOnce).to.be.true
|
|
const destroyWhere = sessionDestroy.firstCall.args[0].where
|
|
expect(destroyWhere.userId).to.equal(userId)
|
|
expect(destroyWhere.id[Op.ne]).to.equal(currentSession.id)
|
|
|
|
expect(result).to.deep.equal({ accessToken: 'access-new', refreshToken: 'refresh-new' })
|
|
})
|
|
|
|
it('admin password reset: deletes all target sessions', async () => {
|
|
const req = makeReq({ requestUserId: 'admin-id', refreshToken: 'refresh-current' })
|
|
const res = { cookie: sinon.spy() }
|
|
|
|
const result = await tokenManager.invalidateJwtSessionsForUser(targetUser, req, res)
|
|
|
|
// Token rotation did not happen because target is a different user
|
|
expect(sessionFindOne.called).to.be.false
|
|
expect(rotateStub.called).to.be.false
|
|
|
|
expect(sessionDestroy.calledOnceWith(sinon.match({ where: { userId } }))).to.be.true
|
|
expect(result).to.equal(null)
|
|
})
|
|
})
|
|
})
|