From 8ec547e30289ca61ebc52b13d150e3a2a6c6a752 Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 16 Jul 2026 18:58:44 +0300 Subject: [PATCH 1/2] fix(auth): refresh tokens authentication in API and WebSocket requests --- server/auth/TokenManager.js | 22 +++- test/server/auth/TokenManager.test.js | 148 ++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 test/server/auth/TokenManager.test.js diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 5933209c7..f0eeea71b 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -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 * Used to authenticate socket connections @@ -76,7 +88,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 } @@ -282,7 +296,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) { diff --git a/test/server/auth/TokenManager.test.js b/test/server/auth/TokenManager.test.js new file mode 100644 index 000000000..ba594be52 --- /dev/null +++ b/test/server/auth/TokenManager.test.js @@ -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) + }) + }) +}) From 70953d8c82a9a6465805de2e073c243e7f8aa8a0 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 21 Jul 2026 17:18:58 -0500 Subject: [PATCH 2/2] Slim down TokenManager test --- test/server/auth/TokenManager.test.js | 110 ++++---------------------- 1 file changed, 15 insertions(+), 95 deletions(-) diff --git a/test/server/auth/TokenManager.test.js b/test/server/auth/TokenManager.test.js index ba594be52..38c739d09 100644 --- a/test/server/auth/TokenManager.test.js +++ b/test/server/auth/TokenManager.test.js @@ -1,12 +1,10 @@ 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. +// 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') @@ -14,6 +12,7 @@ function loadTokenManager() { describe('TokenManager', () => { const secret = 'test-jwt-secret' + const userId = 'user-uuid-1' let TokenManager let tokenManager @@ -23,126 +22,47 @@ describe('TokenManager', () => { 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) - }) + afterEach(() => { + sinon.restore() }) describe('validateAccessToken', () => { - const userId = 'user-uuid-1' - - it('returns null for a refresh token', () => { + it('rejects refresh tokens', () => { 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', () => { + 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') }) - - 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 + const user = { id: userId, username: 'testuser', isActive: true } - 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() + 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.calledOnce).to.equal(true) - expect(done.firstCall.args).to.deep.equal([null, null]) + expect(done.calledWith(null, null)).to.be.true }) 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) + 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.calledOnce).to.equal(true) - expect(done.firstCall.args[1].id).to.equal(user.id) + expect(done.calledWith(null, user)).to.be.true }) }) })