diff --git a/server/auth/TokenManager.js b/server/auth/TokenManager.js index 0c59ae7a1..01cfc4ad7 100644 --- a/server/auth/TokenManager.js +++ b/server/auth/TokenManager.js @@ -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) { diff --git a/test/server/auth/TokenManager.test.js b/test/server/auth/TokenManager.test.js new file mode 100644 index 000000000..38c739d09 --- /dev/null +++ b/test/server/auth/TokenManager.test.js @@ -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 + }) + }) +})