Merge pull request #5387 from mikiher/fix/refresh-token-auth

Reject refresh tokens on API and WebSocket authentication
This commit is contained in:
advplyr
2026-07-21 18:20:57 -04:00
committed by GitHub
2 changed files with 88 additions and 2 deletions
+20 -2
View File
@@ -67,6 +67,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
* Used to authenticate socket connections
@@ -77,7 +89,9 @@ class TokenManager {
*/
static validateAccessToken(token) {
try {
return jwt.verify(token, TokenManager.TokenSecret)
const decoded = jwt.verify(token, TokenManager.TokenSecret)
if (!TokenManager.isBearerAccessTokenPayload(decoded)) return null
return decoded
} catch (err) {
return null
}
@@ -283,7 +297,11 @@ class TokenManager {
done(null, user)
} 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
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
+68
View File
@@ -0,0 +1,68 @@
const { expect } = require('chai')
const sinon = require('sinon')
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
})
})
})