mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-26 19:21:39 +00:00
fix(auth): refresh tokens authentication in API and WebSocket requests
This commit is contained in:
parent
59c7d0275c
commit
8ec547e302
2 changed files with 168 additions and 2 deletions
|
|
@ -66,6 +66,18 @@ class TokenManager {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a decoded JWT payload may authenticate API/socket requests (not refresh-only credentials).
|
||||||
|
*
|
||||||
|
* @param {Object} decoded
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
static isBearerAccessTokenPayload(decoded) {
|
||||||
|
if (!decoded?.userId) return false
|
||||||
|
if (decoded.type === 'refresh') return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to validate a jwt token for a given user
|
* Function to validate a jwt token for a given user
|
||||||
* Used to authenticate socket connections
|
* Used to authenticate socket connections
|
||||||
|
|
@ -76,7 +88,9 @@ class TokenManager {
|
||||||
*/
|
*/
|
||||||
static validateAccessToken(token) {
|
static validateAccessToken(token) {
|
||||||
try {
|
try {
|
||||||
return jwt.verify(token, TokenManager.TokenSecret)
|
const decoded = jwt.verify(token, TokenManager.TokenSecret)
|
||||||
|
if (!TokenManager.isBearerAccessTokenPayload(decoded)) return null
|
||||||
|
return decoded
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
@ -282,7 +296,11 @@ class TokenManager {
|
||||||
|
|
||||||
done(null, user)
|
done(null, user)
|
||||||
} else {
|
} else {
|
||||||
// JWT based authentication
|
// JWT based authentication — refresh tokens are only valid at POST /auth/refresh
|
||||||
|
if (!TokenManager.isBearerAccessTokenPayload(jwt_payload)) {
|
||||||
|
done(null, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the jwt is expired
|
// Check if the jwt is expired
|
||||||
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
|
||||||
|
|
|
||||||
148
test/server/auth/TokenManager.test.js
Normal file
148
test/server/auth/TokenManager.test.js
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const sinon = require('sinon')
|
||||||
|
const { Sequelize } = require('sequelize')
|
||||||
|
|
||||||
|
const Database = require('../../../server/Database')
|
||||||
|
const jwt = require('../../../server/libs/jsonwebtoken')
|
||||||
|
|
||||||
|
// TokenManager is first loaded via Database → Auth → TokenManager while Database is still
|
||||||
|
// initializing, so it can capture a stale Database reference. Reload after Database is ready.
|
||||||
|
function loadTokenManager() {
|
||||||
|
delete require.cache[require.resolve('../../../server/auth/TokenManager')]
|
||||||
|
return require('../../../server/auth/TokenManager')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TokenManager', () => {
|
||||||
|
const secret = 'test-jwt-secret'
|
||||||
|
let TokenManager
|
||||||
|
let tokenManager
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TokenManager = loadTokenManager()
|
||||||
|
TokenManager.TokenSecret = secret
|
||||||
|
tokenManager = new TokenManager()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isBearerAccessTokenPayload', () => {
|
||||||
|
const userId = 'user-uuid-1'
|
||||||
|
|
||||||
|
it('rejects refresh tokens', () => {
|
||||||
|
expect(TokenManager.isBearerAccessTokenPayload({ userId, type: 'refresh' })).to.equal(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts access tokens', () => {
|
||||||
|
expect(TokenManager.isBearerAccessTokenPayload({ userId, type: 'access' })).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts legacy tokens without type', () => {
|
||||||
|
expect(TokenManager.isBearerAccessTokenPayload({ userId })).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects payloads without userId', () => {
|
||||||
|
expect(TokenManager.isBearerAccessTokenPayload({ type: 'access' })).to.equal(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('validateAccessToken', () => {
|
||||||
|
const userId = 'user-uuid-1'
|
||||||
|
|
||||||
|
it('returns null for a refresh token', () => {
|
||||||
|
const refreshToken = jwt.sign({ userId, type: 'refresh' }, secret, { expiresIn: 3600 })
|
||||||
|
expect(TokenManager.validateAccessToken(refreshToken)).to.equal(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns decoded payload for an access token', () => {
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns decoded payload for a legacy token without type', () => {
|
||||||
|
const legacyToken = jwt.sign({ userId, username: 'testuser' }, secret)
|
||||||
|
const decoded = TokenManager.validateAccessToken(legacyToken)
|
||||||
|
expect(decoded.userId).to.equal(userId)
|
||||||
|
expect(decoded.type).to.equal(undefined)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('jwtAuthCheck', () => {
|
||||||
|
let user
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
|
||||||
|
Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
|
||||||
|
await Database.buildModels()
|
||||||
|
|
||||||
|
user = await Database.userModel.create({
|
||||||
|
username: 'tokenuser',
|
||||||
|
pash: 'hash',
|
||||||
|
token: 'legacy-token',
|
||||||
|
type: 'user',
|
||||||
|
isActive: true,
|
||||||
|
permissions: Database.userModel.getDefaultPermissionsForUserType('user'),
|
||||||
|
bookmarks: [],
|
||||||
|
extraData: {}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (Database.sequelize) {
|
||||||
|
await Database.sequelize.sync({ force: true })
|
||||||
|
Database.sequelize = null
|
||||||
|
}
|
||||||
|
sinon.restore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('denies refresh tokens on resource authentication', async () => {
|
||||||
|
const done = sinon.spy()
|
||||||
|
const refreshToken = tokenManager.generateRefreshToken(user)
|
||||||
|
const decoded = jwt.verify(refreshToken, secret)
|
||||||
|
|
||||||
|
await tokenManager.jwtAuthCheck(decoded, done)
|
||||||
|
|
||||||
|
expect(done.calledOnce).to.equal(true)
|
||||||
|
expect(done.firstCall.args).to.deep.equal([null, null])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows access tokens for active users', async () => {
|
||||||
|
const done = sinon.spy()
|
||||||
|
const accessToken = tokenManager.generateTempAccessToken(user)
|
||||||
|
const decoded = jwt.verify(accessToken, secret)
|
||||||
|
|
||||||
|
await tokenManager.jwtAuthCheck(decoded, done)
|
||||||
|
|
||||||
|
expect(done.calledOnce).to.equal(true)
|
||||||
|
expect(done.firstCall.args[1].id).to.equal(user.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows legacy tokens without type for active users', async () => {
|
||||||
|
const done = sinon.spy()
|
||||||
|
const legacyToken = TokenManager.generateAccessToken({ id: user.id, username: user.username })
|
||||||
|
const decoded = jwt.verify(legacyToken, secret)
|
||||||
|
|
||||||
|
await tokenManager.jwtAuthCheck(decoded, done)
|
||||||
|
|
||||||
|
expect(done.calledOnce).to.equal(true)
|
||||||
|
expect(done.firstCall.args[1].id).to.equal(user.id)
|
||||||
|
expect(done.firstCall.args[1].isOldToken).to.equal(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('authenticates API keys via the api branch', async () => {
|
||||||
|
const apiKey = await Database.apiKeyModel.create({
|
||||||
|
name: 'test-key',
|
||||||
|
userId: user.id,
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: null
|
||||||
|
})
|
||||||
|
const apiToken = jwt.sign({ type: 'api', keyId: apiKey.id, exp: Math.floor(Date.now() / 1000) + 3600 }, secret)
|
||||||
|
const decoded = jwt.verify(apiToken, secret)
|
||||||
|
const done = sinon.spy()
|
||||||
|
|
||||||
|
await tokenManager.jwtAuthCheck(decoded, done)
|
||||||
|
|
||||||
|
expect(done.calledOnce).to.equal(true)
|
||||||
|
expect(done.firstCall.args[1].id).to.equal(user.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue