diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 260c773..4e3604c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - node: [18, 20, 22] + node: [20, 22] services: redis: image: redis diff --git a/package.json b/package.json index e58a12c..b0d00d7 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ }, "homepage": "https://github.com/Brightspace/node-auth#readme", "engines": { - "node": ">=18.x" + "node": ">=20.x" }, "private": true, "scripts": { @@ -23,6 +23,7 @@ "test": "npm run check-deps && npm run test-all" }, "dependencies": { + "jose": "^6.0.11", "jwk-allowed-algorithms": "^1.0.0", "jws": "^4.0.0", "superagent": "^7.1.3" @@ -35,7 +36,6 @@ "eslint": "^9.26.0", "eslint-config-brightspace": "^2.7.2", "find-requires": "^1.0.0", - "jsonwebtoken": "^8.1.0", "mocha": "^11.1.0", "nyc": "^17.1.0", "redis": "^2.8.0", diff --git a/packages/node_modules/brightspace-auth-validation/spec/validation.spec.js b/packages/node_modules/brightspace-auth-validation/spec/validation.spec.js index 7c3208f..161030a 100644 --- a/packages/node_modules/brightspace-auth-validation/spec/validation.spec.js +++ b/packages/node_modules/brightspace-auth-validation/spec/validation.spec.js @@ -7,7 +7,7 @@ const chai = require('chai'), chaiAsPromised = require('chai-as-promised'), expect = chai.expect, - jwt = require('jsonwebtoken'), + jose = require('jose/jwt/sign'), sinon = require('sinon'), { MockAgent, setGlobalDispatcher } = require('undici'); @@ -39,14 +39,11 @@ describe('validations', function() { validator; const { publicKey, privateKey } = generateKeyPairSync('rsa', { - modulusLength: 512, + modulusLength: 2048, publicKeyEncoding: { format: 'jwk' }, - privateKeyEncoding: { - type: 'pkcs8', - format: 'pem' - } + privateKeyEncoding: null }); const jwk = Object.assign({}, publicKey, { kid: 'foo-bar-baz', @@ -86,42 +83,33 @@ describe('validations', function() { it('should throw "BadToken" when invalid token is sent', function() { return expect(validator.fromHeaders({ authorization: 'Bearer foobarbaz' })) - .to.be.rejectedWith(AuthTokenValidator.errors.BadToken); + .to.be.rejectedWith(AuthTokenValidator.errors.BadToken, 'Not a valid JWT'); }); - it('should throw "BadToken" when expired token is sent', function() { - token = jwt.sign({}, privateKey, { - algorithm: 'RS256', - header: { - kid: 'foo-bar-baz' - }, - expiresIn: -1 * (maxClockSkew) - }); + it('should throw "BadToken" when expired token is sent', async function() { + token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: jwk.kid }) + .setExpirationTime(Math.round(Date.now() / 1000) - maxClockSkew) + .sign(privateKey); return expect(validator.fromHeaders({ authorization: `Bearer ${ token }` })) - .to.be.rejectedWith(AuthTokenValidator.errors.BadToken); + .to.be.rejectedWith(AuthTokenValidator.errors.BadToken, /^Token expired/); }); - it('should throw "BadToken" when not-yet-valid token is sent (outside of skew)', function() { - token = jwt.sign({}, privateKey, { - algorithm: 'RS256', - header: { - kid: 'foo-bar-baz' - }, - notBefore: maxClockSkew + 1 - }); + it('should throw "BadToken" when not-yet-valid token is sent (outside of skew)', async function() { + token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: jwk.kid }) + .setNotBefore(Math.round(Date.now() / 1000) + maxClockSkew + 1) + .sign(privateKey); return expect(validator.fromHeaders({ authorization: `Bearer ${ token }` })) - .to.be.rejectedWith(AuthTokenValidator.errors.BadToken); + .to.be.rejectedWith(AuthTokenValidator.errors.BadToken, /^Token not yet valid/); }); it('should throw "BadToken" for bad signature', async function() { - token = jwt.sign({}, privateKey, { - algorithm: 'RS256', - header: { - kid: 'foo-bar-baz' - } - }); + token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: jwk.kid }) + .sign(privateKey); token += 'mess-up-the-signature'; @@ -130,16 +118,13 @@ describe('validations', function() { })); await expect(validator.fromHeaders({ authorization: `Bearer ${token}` })) - .to.be.rejectedWith(AuthTokenValidator.errors.BadToken); + .to.be.rejectedWith(AuthTokenValidator.errors.BadToken, 'Signature verification failed'); }); it('should throw "PublicKeyNotFound" when no key with matching "kid" is found on auth server', async function() { - token = jwt.sign({}, privateKey, { - algorithm: 'RS256', - header: { - kid: 'errmegerd' - } - }); + token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: 'errmegerd' }) + .sign(privateKey); interceptJwks(x => x.reply(200, { keys: [jwk] @@ -150,12 +135,9 @@ describe('validations', function() { }); it('should throw "PublicKeyNotFound" when key with matching "kid" was found, but had a past exp', async function() { - token = jwt.sign({}, privateKey, { - algorithm: 'RS256', - header: { - kid: jwk.kid - } - }); + token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: jwk.kid }) + .sign(privateKey); interceptJwks(x => x.reply(200, { keys: [Object.assign({}, jwk, { exp: Math.round(Date.now() / 1000) - maxClockSkew })] @@ -166,12 +148,9 @@ describe('validations', function() { }); it('should throw "PublicKeyLookupFailed" when there is an error requesting the jwks', async function() { - token = jwt.sign({}, privateKey, { - algorithm: 'RS256', - header: { - kid: 'errmegerd' - } - }); + token = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'RS256', kid: jwk.kid }) + .sign(privateKey); interceptJwks(x => x.reply(404)); @@ -180,133 +159,90 @@ describe('validations', function() { }); it('should NOT throw "PublicKeyLookupFailed" when there WAS error requesting the jwks', async function() { - token = jwt.sign({}, privateKey, { - algorithm: 'RS256', - header: { - kid: 'errmegerd' - } - }); + token = await new jose.SignJWT({ key: 'val' }) + .setProtectedHeader({ alg: 'RS256', kid: jwk.kid }) + .sign(privateKey); interceptJwks(x => x.reply(404)); await expect(validator.fromHeaders({ authorization: `Bearer ${ token }` })) .to.be.rejectedWith(AuthTokenValidator.errors.PublicKeyLookupFailed); - const - payload = { - key: 'val' - }, - signature = jwt.sign(payload, privateKey, { - algorithm: 'RS256', - header: { - kid: 'foo-bar-baz' - } - }); - interceptJwks(x => x.reply(200, { keys: [jwk] })); - token = await validator.fromHeaders({ - authorization: `Bearer ${ signature }` + const res = await validator.fromHeaders({ + authorization: `Bearer ${ token }` }); - expect(token).to.be.instanceof(BrightspaceAuthToken); - expect(token.source).to.equal(signature); + expect(res).to.be.instanceof(BrightspaceAuthToken); + expect(res.source).to.equal(token); }); it('should return BrightspaceAuthToken when matching "kid" is found on auth server and signature is valid', async function() { - const - payload = { - key: 'val' - }, - signature = jwt.sign(payload, privateKey, { - algorithm: 'RS256', - header: { - kid: 'foo-bar-baz' - } - }); + token = await new jose.SignJWT({ key: 'val' }) + .setProtectedHeader({ alg: 'RS256', kid: jwk.kid }) + .sign(privateKey); interceptJwks(x => x.reply(200, { keys: [jwk] })); - token = await validator.fromHeaders({ - authorization: `Bearer ${ signature }` + const res = await validator.fromHeaders({ + authorization: `Bearer ${ token }` }); - expect(token).to.be.instanceof(BrightspaceAuthToken); - expect(token.source).to.equal(signature); + expect(res).to.be.instanceof(BrightspaceAuthToken); + expect(res.source).to.equal(token); }); it('should return BrightspaceAuthToken when matching "kid" is found on auth server, signature is valid, and expiry is within clock skew', async function() { - const - payload = { - key: 'val' - }, - signature = jwt.sign(payload, privateKey, { - algorithm: 'RS256', - header: { - kid: 'foo-bar-baz' - }, - expiresIn: -1 * maxClockSkew + 1 - }); + token = await new jose.SignJWT({ key: 'val' }) + .setProtectedHeader({ alg: 'RS256', kid: jwk.kid }) + .setExpirationTime(Math.round(Date.now() / 1000) - maxClockSkew + 1) + .sign(privateKey); interceptJwks(x => x.reply(200, { keys: [jwk] })); - token = await validator.fromHeaders({ - authorization: `Bearer ${ signature }` + const res = await validator.fromHeaders({ + authorization: `Bearer ${ token }` }); - expect(token).to.be.instanceof(BrightspaceAuthToken); - expect(token.source).to.equal(signature); + expect(res).to.be.instanceof(BrightspaceAuthToken); + expect(res.source).to.equal(token); }); it('should return BrightspaceAuthToken when matching "kid" is found on auth server, signature is valid and nbf is within clock skew', async function() { - const - payload = { - key: 'val' - }, - signature = jwt.sign(payload, privateKey, { - algorithm: 'RS256', - header: { - kid: 'foo-bar-baz' - }, - notBefore: maxClockSkew - }); + token = await new jose.SignJWT({ key: 'val' }) + .setProtectedHeader({ alg: 'RS256', kid: jwk.kid }) + .setNotBefore(Math.round(Date.now() / 1000) + maxClockSkew) + .sign(privateKey); interceptJwks(x => x.reply(200, { keys: [jwk] })); - token = await validator.fromHeaders({ - authorization: `Bearer ${ signature }` + const res = await validator.fromHeaders({ + authorization: `Bearer ${ token }` }); - expect(token).to.be.instanceof(BrightspaceAuthToken); - expect(token.source).to.equal(signature); + expect(res).to.be.instanceof(BrightspaceAuthToken); + expect(res.source).to.equal(token); }); it('should return BrightspaceAuthToken even when more than one space separates "Bearer" and the signature', async function() { - const - payload = { - key: 'val' - }, - signature = jwt.sign(payload, privateKey, { - algorithm: 'RS256', - header: { - kid: 'foo-bar-baz' - }, - expiresIn: -1 * maxClockSkew + 1 - }); + token = await new jose.SignJWT({ key: 'val' }) + .setProtectedHeader({ alg: 'RS256', kid: jwk.kid }) + .sign(privateKey); interceptJwks(x => x.reply(200, { keys: [jwk] })); - token = await validator.fromHeaders({ - authorization: `Bearer ${ signature }` + const res = await validator.fromHeaders({ + authorization: `Bearer ${ token }` }); - expect(token).to.be.instanceof(BrightspaceAuthToken); - expect(token.source).to.equal(signature); + expect(res).to.be.instanceof(BrightspaceAuthToken); + expect(res.source).to.equal(token); }); describe('validateConfiguration', function() { diff --git a/packages/node_modules/brightspace-auth-validation/src/errors.js b/packages/node_modules/brightspace-auth-validation/src/errors.js index 46ca876..79cf2d6 100644 --- a/packages/node_modules/brightspace-auth-validation/src/errors.js +++ b/packages/node_modules/brightspace-auth-validation/src/errors.js @@ -1,13 +1,19 @@ 'use strict'; class BadJsonWebTokenError extends Error { - constructor(message) { + constructor(message, inner) { super(message); this.name = this.constructor.name; this.status = 401; - Error.captureStackTrace(this, this.constructor); + this.inner = inner; + + if (inner && inner.stack) { + this.stack = inner.stack; + } else { + Error.captureStackTrace(this, this.constructor); + } } } diff --git a/packages/node_modules/brightspace-auth-validation/src/index.js b/packages/node_modules/brightspace-auth-validation/src/index.js index 7c6036c..67808e6 100644 --- a/packages/node_modules/brightspace-auth-validation/src/index.js +++ b/packages/node_modules/brightspace-auth-validation/src/index.js @@ -4,7 +4,12 @@ const assert = require('assert'); const { createPublicKey } = require('crypto'); const jwkAllowedAlgorithms = require('jwk-allowed-algorithms'); -const jws = require('jws'); + +const jose = { + decodeHeader: require('jose/decode/protected_header').decodeProtectedHeader, + decodePayload: require('jose/jwt/decode').decodeJwt, + verify: require('jose/jws/compact/verify').compactVerify, +}; const AuthToken = require('brightspace-auth-token'); @@ -116,7 +121,7 @@ class AuthTokenValidator { const claims = this._validateClaims(token); const publicKey = await this._getPublicKey(token); - verifySignature(signature, token, publicKey); + await verifySignature(signature, token, publicKey); return new AuthToken(claims, signature); } @@ -223,19 +228,13 @@ class AuthTokenValidator { function decodeSignature(signature) { assert('string' === typeof signature); - let decodedToken = null; + let header = null; try { - decodedToken = jws.decode(signature); + header = jose.decodeHeader(signature); } catch (ignore) { throw new errors.BadToken('Not a valid JWT'); } - if (!decodedToken) { - throw new errors.BadToken('Not a valid JWT'); - } - - const header = decodedToken.header; - if ('string' !== typeof header.kid) { throw new errors.BadToken('Missing "kid" header'); } @@ -244,22 +243,23 @@ function decodeSignature(signature) { throw new errors.BadToken('Missing "alg" header'); } - return decodedToken; + let payload = null; + try { + payload = jose.decodePayload(signature); + } catch (ignore) { + throw new errors.BadToken('Not a valid JWT'); + } + + return { header, payload }; } -function verifySignature(signature, token, publicKey) { +async function verifySignature(signature, token, publicKey) { const alg = matchAlgorithm(publicKey, token); - let verified = false; try { - verified = jws.verify(signature, alg, publicKey.key); + await jose.verify(signature, publicKey.key, { algorithms: [alg] }); } catch (e) { - process._rawDebug(e); - throw new errors.BadToken('Error during signature verification'); - } - - if (!verified) { - throw new errors.BadToken('Invalid signature'); + throw new errors.BadToken('Signature verification failed', e); } }