diff --git a/package.json b/package.json index 1083adf..4aa5f80 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "dependencies": { "@fastify/error": "^4.2.0", "@lukeed/ms": "^2.0.2", - "fast-jwt": "^6.2.4", + "fast-jwt": "^6.3.3", "fastify-plugin": "^6.0.0", "steed": "^1.1.3" }, diff --git a/test/algorithm-confusion.test.js b/test/algorithm-confusion.test.js new file mode 100644 index 0000000..3dd763e --- /dev/null +++ b/test/algorithm-confusion.test.js @@ -0,0 +1,83 @@ +'use strict' + +const { test } = require('node:test') +const { createHmac, createPublicKey } = require('node:crypto') +const Fastify = require('fastify') +const jwt = require('..') + +const helper = require('./helper') + +const { publicKey } = helper.generateKeyPair() +const zeroWidthSpace = '\u200b' + +function forgeHS256Token (secret, payload) { + const encode = part => Buffer.from(JSON.stringify(part)).toString('base64url') + const data = `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}` + const signature = createHmac('sha256', secret).update(data).digest('base64url') + return `${data}.${signature}` +} + +async function verifyForgedToken (key) { + const fastify = Fastify() + fastify.register(jwt, { secret: async function () { return key } }) + + fastify.get('/protected', async function (request) { + return request.jwtVerify() + }) + + await fastify.ready() + + const response = await fastify.inject({ + method: 'GET', + url: '/protected', + headers: { authorization: `Bearer ${forgeHS256Token(key, { sub: 'attacker' })}` } + }) + + await fastify.close() + return response +} + +test('public key material is not usable as an HMAC secret', async function (t) { + t.plan(3) + + await t.test('serialized asymmetric JWK', async function (t) { + t.plan(2) + + const jwk = JSON.stringify(createPublicKey(publicKey).export({ format: 'jwk' })) + const response = await verifyForgedToken(jwk) + + t.assert.strictEqual(response.statusCode, 401) + t.assert.strictEqual(JSON.parse(response.payload).code, 'FST_JWT_AUTHORIZATION_TOKEN_INVALID') + }) + + await t.test('serialized asymmetric JWKS', async function (t) { + t.plan(2) + + const jwks = JSON.stringify({ keys: [createPublicKey(publicKey).export({ format: 'jwk' })] }) + const response = await verifyForgedToken(jwks) + + t.assert.strictEqual(response.statusCode, 401) + t.assert.strictEqual(JSON.parse(response.payload).code, 'FST_JWT_AUTHORIZATION_TOKEN_INVALID') + }) + + await t.test('PEM hidden behind a zero width character', async function (t) { + t.plan(1) + + const response = await verifyForgedToken(`${zeroWidthSpace}${publicKey}`) + + t.assert.notStrictEqual(response.statusCode, 200, 'the forged HS256 token must not authenticate the request') + }) +}) + +test('a symmetric JWK is still usable as an HMAC secret', async function (t) { + t.plan(1) + + const fastify = Fastify() + fastify.register(jwt, { secret: JSON.stringify({ kty: 'oct', k: 'c3VwZXJzZWNyZXQ' }) }) + await fastify.ready() + + const token = fastify.jwt.sign({ foo: 'bar' }) + t.assert.strictEqual(fastify.jwt.verify(token).foo, 'bar') + + await fastify.close() +}) diff --git a/test/jwt.test.js b/test/jwt.test.js index fa79532..d05e88a 100644 --- a/test/jwt.test.js +++ b/test/jwt.test.js @@ -3133,3 +3133,27 @@ test('local sign options should not overwrite global sign options', async functi t.assert.strictEqual(fastify.jwt.options.sign.expiresIn, '15m') }) + +test('"expiresIn" and "notBefore" take precedence over "exp" and "nbf" in the payload', async function (t) { + t.plan(6) + + const fastify = Fastify() + fastify.register(jwt, { secret: 'test' }) + await fastify.ready() + + const oneHourInSeconds = 60 * 60 + const nowInSeconds = Math.floor(Date.now() / 1000) + const payloadClaim = nowInSeconds + 24 * oneHourInSeconds + + const withExpiresIn = fastify.jwt.decode(fastify.jwt.sign({ foo: 'bar', exp: payloadClaim }, { expiresIn: '1h' })) + t.assert.notStrictEqual(withExpiresIn.exp, payloadClaim) + t.assert.ok(Math.abs(withExpiresIn.exp - (nowInSeconds + oneHourInSeconds)) <= 5) + + const withNotBefore = fastify.jwt.decode(fastify.jwt.sign({ foo: 'bar', nbf: payloadClaim }, { notBefore: '1h' })) + t.assert.notStrictEqual(withNotBefore.nbf, payloadClaim) + t.assert.ok(Math.abs(withNotBefore.nbf - (nowInSeconds + oneHourInSeconds)) <= 5) + + const withoutSignOptions = fastify.jwt.decode(fastify.jwt.sign({ foo: 'bar', exp: payloadClaim, nbf: payloadClaim })) + t.assert.strictEqual(withoutSignOptions.exp, payloadClaim) + t.assert.strictEqual(withoutSignOptions.nbf, payloadClaim) +}) diff --git a/test/options.test.js b/test/options.test.js index b9cd5c7..5c0b8e2 100644 --- a/test/options.test.js +++ b/test/options.test.js @@ -6,7 +6,7 @@ const jwt = require('..') const { AssertionError } = require('node:assert') test('Options validation', async function (t) { - t.plan(3) + t.plan(4) await t.test('Options are required', async function (t) { t.plan(1) @@ -144,4 +144,16 @@ test('Options validation', async function (t) { }) }) }) + + await t.test('Verify options', async function (t) { + t.plan(3) + + const fastify = Fastify() + await fastify.register(jwt, { secret: 'test' }) + const token = fastify.jwt.sign({ foo: 'bar' }) + + t.assert.strictEqual(fastify.jwt.verify(token, { cacheTTL: Infinity }).foo, 'bar') + t.assert.throws(() => fastify.jwt.verify(token, { cacheTTL: NaN }), { code: 'FAST_JWT_INVALID_OPTION' }) + t.assert.throws(() => fastify.jwt.verify(token, { clockTolerance: Infinity }), { code: 'FAST_JWT_INVALID_OPTION' }) + }) })