From d69e0bd43b25a60e2954ae106df2021ec82b412e Mon Sep 17 00:00:00 2001 From: Lukas Hroch Date: Sat, 18 Jan 2025 17:59:13 +0000 Subject: [PATCH] fix: swap jsonwebtoken with jose - support esm/cjs treeshakable without transient dependencies to support various envs --- README.md | 2 +- example/src/index.js | 2 +- package-lock.json | 144 +++--------------------------- package.json | 3 +- src/client.ts | 63 +++++++------ src/constants.ts | 2 +- test/client/auth-url.test.ts | 52 +++++------ test/client/client.test.ts | 12 +-- test/client/health-check.test.ts | 2 +- test/client/token-exchage.test.ts | 79 +++++++++------- 10 files changed, 126 insertions(+), 235 deletions(-) diff --git a/README.md b/README.md index 93034bc..30b2dea 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ const state = client.generateState(); Creates authentication URL to redirect user to Duo Security Universal prompt. Provide user identifier and state generated in previous step. ```ts -const authUrl = client.createAuthUrl('username', 'state'); +const authUrl = await client.createAuthUrl('username', 'state'); ``` ### 6. Token & code exchange diff --git a/example/src/index.js b/example/src/index.js index 5e8a84e..fcaf2c8 100644 --- a/example/src/index.js +++ b/example/src/index.js @@ -43,7 +43,7 @@ const startApp = async () => { const state = duoClient.generateState(); req.session.duo = { state, username }; - const url = duoClient.createAuthUrl(username, state); + const url = await duoClient.createAuthUrl(username, state); res.redirect(302, url); } catch (err) { diff --git a/package-lock.json b/package-lock.json index 413eea9..2bc8dd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,12 +10,11 @@ "license": "MIT", "dependencies": { "axios": "^1.7.9", - "jsonwebtoken": "^9.0.2" + "jose": "^5.9.6" }, "devDependencies": { "@eslint/js": "^9.18.0", "@types/jest": "^29.5.14", - "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.6", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", @@ -1599,16 +1598,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", - "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/node": { "version": "22.10.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.6.tgz", @@ -2246,12 +2235,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2635,15 +2618,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4333,6 +4307,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/joycon": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", @@ -4417,49 +4400,6 @@ "node": ">=6" } }, - "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "license": "MIT", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4550,42 +4490,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -4600,12 +4504,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, "node_modules/lodash.sortby": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", @@ -4745,6 +4643,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -5445,30 +5344,11 @@ "queue-microtask": "^1.2.2" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/semver": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" diff --git a/package.json b/package.json index d6977da..6bb29f0 100644 --- a/package.json +++ b/package.json @@ -48,12 +48,11 @@ }, "dependencies": { "axios": "^1.7.9", - "jsonwebtoken": "^9.0.2" + "jose": "^5.9.6" }, "devDependencies": { "@eslint/js": "^9.18.0", "@types/jest": "^29.5.14", - "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.6", "eslint": "^9.18.0", "eslint-config-prettier": "^10.0.1", diff --git a/src/client.ts b/src/client.ts index 7644f34..5a23aa9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5,14 +5,13 @@ import axios, { AxiosInstance } from 'axios'; import https from 'https'; -import jwt from 'jsonwebtoken'; +import { SignJWT, jwtVerify } from 'jose'; import { URL, URLSearchParams } from 'url'; import * as constants from './constants'; import { DuoException } from './duo-exception'; import { AuthorizationRequest, AuthorizationRequestPayload, - ClientPayload, HealthCheckRequest, HealthCheckResponse, TokenRequest, @@ -38,7 +37,7 @@ export class Client { private clientId: string; - private clientSecret: string; + private clientSecret: Uint8Array; private apiHost: string; @@ -56,7 +55,7 @@ export class Client { const { clientId, clientSecret, apiHost, redirectUrl, useDuoCodeAttribute } = options; this.clientId = clientId; - this.clientSecret = clientSecret; + this.clientSecret = new TextEncoder().encode(clientSecret); this.apiHost = apiHost; this.baseURL = `https://${this.apiHost}`; this.redirectUrl = redirectUrl; @@ -93,7 +92,7 @@ export class Client { try { new URL(redirectUrl); - } catch (err) { + } catch { throw new DuoException(constants.PARSING_CONFIG_ERROR); } } @@ -124,19 +123,21 @@ export class Client { * @returns {string} * @memberof Client */ - private createJwtPayload(audience: string): string { + private async createJwtPayload(audience: string): Promise { const timeInSecs = getTimeInSeconds(); - const payload: ClientPayload = { + const jwt = await new SignJWT({ iss: this.clientId, sub: this.clientId, aud: audience, jti: generateRandomString(constants.JTI_LENGTH), iat: timeInSecs, exp: timeInSecs + constants.JWT_EXPIRATION, - }; + }) + .setProtectedHeader({ alg: constants.SIG_ALGORITHM }) + .sign(this.clientSecret); - return jwt.sign(payload, this.clientSecret, { algorithm: constants.SIG_ALGORITHM }); + return jwt; } /** @@ -151,22 +152,18 @@ export class Client { private async verifyToken(token: string): Promise { const tokenEndpoint = `${this.baseURL}${this.TOKEN_ENDPOINT}`; const clientId = this.clientId; - return new Promise((resolve, reject) => { - jwt.verify( - token, - this.clientSecret, - { - algorithms: [constants.SIG_ALGORITHM], - clockTolerance: constants.JWT_LEEWAY, - issuer: tokenEndpoint, - audience: clientId, - }, - (err, decoded) => - err || !decoded - ? reject(new DuoException(constants.JWT_DECODE_ERROR, err)) - : resolve(decoded as unknown as T) - ); - }); + try { + const decoded = await jwtVerify(token, this.clientSecret, { + algorithms: [constants.SIG_ALGORITHM], + clockTolerance: constants.JWT_LEEWAY, + issuer: tokenEndpoint, + audience: clientId, + }); + + return decoded.payload; + } catch (err) { + throw new DuoException(constants.JWT_DECODE_ERROR, err instanceof Error ? err : undefined); + } } /** @@ -208,7 +205,7 @@ export class Client { */ async healthCheck(): Promise { const audience = `${this.baseURL}${this.HEALTH_CHECK_ENDPOINT}`; - const jwtPayload = this.createJwtPayload(audience); + const jwtPayload = await this.createJwtPayload(audience); const request: HealthCheckRequest = { client_id: this.clientId, client_assertion: jwtPayload, @@ -217,7 +214,7 @@ export class Client { try { const { data } = await this.axios.post( this.HEALTH_CHECK_ENDPOINT, - new URLSearchParams(request) + new URLSearchParams(request), ); const { stat } = data; @@ -237,7 +234,7 @@ export class Client { * @returns {string} * @memberof Client */ - createAuthUrl(username: string, state: string): string { + async createAuthUrl(username: string, state: string): Promise { if (!username) throw new DuoException(constants.DUO_USERNAME_ERROR); if ( @@ -262,7 +259,9 @@ export class Client { use_duo_code_attribute: this.useDuoCodeAttribute, }; - const request = jwt.sign(payload, this.clientSecret, { algorithm: constants.SIG_ALGORITHM }); + const request = await new SignJWT(payload) + .setProtectedHeader({ alg: constants.SIG_ALGORITHM }) + .sign(this.clientSecret); const query: AuthorizationRequest = { response_type: 'code', @@ -287,14 +286,14 @@ export class Client { async exchangeAuthorizationCodeFor2FAResult( code: string, username: string, - nonce: string | null = null + nonce: string | null = null, ): Promise { if (!code) throw new DuoException(constants.MISSING_CODE_ERROR); if (!username) throw new DuoException(constants.USERNAME_ERROR); const tokenEndpoint = `${this.baseURL}${this.TOKEN_ENDPOINT}`; - const jwtPayload = this.createJwtPayload(tokenEndpoint); + const jwtPayload = await this.createJwtPayload(tokenEndpoint); const request: TokenRequest = { grant_type: constants.GRANT_TYPE, @@ -313,7 +312,7 @@ export class Client { headers: { 'user-agent': `${constants.USER_AGENT} node/${process.versions.node} v8/${process.versions.v8}`, }, - } + }, ); /* Verify that we are receiving the expected response from Duo */ diff --git a/src/constants.ts b/src/constants.ts index 73d191c..8342525 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -7,7 +7,7 @@ import fs from 'fs'; import path from 'path'; const pkg = JSON.parse( - fs.readFileSync(path.join(__dirname, '../package.json'), { encoding: 'utf-8' }) + fs.readFileSync(path.join(__dirname, '../package.json'), { encoding: 'utf-8' }), ); export const CLIENT_ID_LENGTH = 20; diff --git a/test/client/auth-url.test.ts b/test/client/auth-url.test.ts index c3843d4..7b432c9 100644 --- a/test/client/auth-url.test.ts +++ b/test/client/auth-url.test.ts @@ -4,7 +4,7 @@ // SPDX-License-Identifier: MIT import axios from 'axios'; -import jwt from 'jsonwebtoken'; +import { jwtVerify } from 'jose'; import { URL } from 'url'; import { Client, DuoException, constants, util } from '../../src'; @@ -28,45 +28,50 @@ describe('Authentication URL', () => { const client = new Client(clientOps); const shortLengthState = util.generateRandomString(constants.MIN_STATE_LENGTH - 1); - expect(() => { - client.createAuthUrl(username, shortLengthState); - }).toThrowWithMessage(DuoException, constants.DUO_STATE_ERROR); + expect(client.createAuthUrl(username, shortLengthState)).rejects.toThrowWithMessage( + DuoException, + constants.DUO_STATE_ERROR, + ); }); it('should thrown if state is long for authentication URL', () => { const client = new Client(clientOps); const longLengthState = util.generateRandomString(constants.MAX_STATE_LENGTH + 1); - expect(() => { - client.createAuthUrl(username, longLengthState); - }).toThrowWithMessage(DuoException, constants.DUO_STATE_ERROR); + expect(client.createAuthUrl(username, longLengthState)).rejects.toThrowWithMessage( + DuoException, + constants.DUO_STATE_ERROR, + ); }); it('should throw if state is null for authentication URL', () => { const client = new Client(clientOps); - expect(() => { - client.createAuthUrl(username, null as any); - }).toThrowWithMessage(DuoException, constants.DUO_STATE_ERROR); + expect(client.createAuthUrl(username, null as any)).rejects.toThrowWithMessage( + DuoException, + constants.DUO_STATE_ERROR, + ); }); it('should throw if username is null for authentication URL', () => { const client = new Client(clientOps); const state = client.generateState(); - expect(() => { - client.createAuthUrl(null as any, state); - }).toThrowWithMessage(DuoException, constants.DUO_USERNAME_ERROR); + expect(client.createAuthUrl(null as any, state)).rejects.toThrowWithMessage( + DuoException, + constants.DUO_USERNAME_ERROR, + ); }); - it(`should create correct authentication URL (default 'useDuoCodeAttribute')`, () => { + it(`should create correct authentication URL (default 'useDuoCodeAttribute')`, async () => { expect.assertions(7); const client = new Client(clientOps); + const secret = new TextEncoder().encode(clientOps.clientSecret); const state = client.generateState(); const { host, protocol, pathname, searchParams } = new URL( - client.createAuthUrl(username, state) + await client.createAuthUrl(username, state), ); const request = searchParams.get('request'); @@ -79,21 +84,20 @@ describe('Authentication URL', () => { expect(request).not.toBe(null); if (request) { - const token = jwt.verify(request, clientOps.clientSecret, { - algorithms: [constants.SIG_ALGORITHM], - }); - expect((token as any).use_duo_code_attribute).toBe(true); + const token = await jwtVerify(request, secret, { algorithms: [constants.SIG_ALGORITHM] }); + expect(token.payload.use_duo_code_attribute).toBe(true); } }); - it(`should create correct authentication URL (explicit 'useDuoCodeAttribute')`, () => { + it(`should create correct authentication URL (explicit 'useDuoCodeAttribute')`, async () => { expect.assertions(7); const client = new Client({ ...clientOps, useDuoCodeAttribute: false }); + const secret = new TextEncoder().encode(clientOps.clientSecret); const state = client.generateState(); const { host, protocol, pathname, searchParams } = new URL( - client.createAuthUrl(username, state) + await client.createAuthUrl(username, state), ); const request = searchParams.get('request'); @@ -106,10 +110,8 @@ describe('Authentication URL', () => { expect(request).not.toBe(null); if (request) { - const token = jwt.verify(request, clientOps.clientSecret, { - algorithms: [constants.SIG_ALGORITHM], - }); - expect((token as any).use_duo_code_attribute).toBe(false); + const token = await jwtVerify(request, secret, { algorithms: [constants.SIG_ALGORITHM] }); + expect(token.payload.use_duo_code_attribute).toBe(false); } }); }); diff --git a/test/client/client.test.ts b/test/client/client.test.ts index a19dbb1..3e5a0ee 100644 --- a/test/client/client.test.ts +++ b/test/client/client.test.ts @@ -32,7 +32,7 @@ describe('Client instance', () => { expect(() => new Client({ ...clientOps, clientId: shortClientId })).toThrowWithMessage( DuoException, - constants.INVALID_CLIENT_ID_ERROR + constants.INVALID_CLIENT_ID_ERROR, ); }); @@ -41,7 +41,7 @@ describe('Client instance', () => { expect(() => new Client({ ...clientOps, clientId: longClientId })).toThrowWithMessage( DuoException, - constants.INVALID_CLIENT_ID_ERROR + constants.INVALID_CLIENT_ID_ERROR, ); }); @@ -50,7 +50,7 @@ describe('Client instance', () => { expect(() => new Client({ ...clientOps, clientSecret: shortClientSecret })).toThrowWithMessage( DuoException, - constants.INVALID_CLIENT_SECRET_ERROR + constants.INVALID_CLIENT_SECRET_ERROR, ); }); @@ -59,21 +59,21 @@ describe('Client instance', () => { expect(() => new Client({ ...clientOps, clientSecret: longClientSecret })).toThrowWithMessage( DuoException, - constants.INVALID_CLIENT_SECRET_ERROR + constants.INVALID_CLIENT_SECRET_ERROR, ); }); it('should throw during new client creation with invalid API host', () => { expect(() => new Client({ ...clientOps, apiHost: '' })).toThrowWithMessage( DuoException, - constants.PARSING_CONFIG_ERROR + constants.PARSING_CONFIG_ERROR, ); }); it('should throw during new client creation with invalid redirect URL', () => { expect(() => new Client({ ...clientOps, redirectUrl: 'notAnUrl' })).toThrowWithMessage( DuoException, - constants.PARSING_CONFIG_ERROR + constants.PARSING_CONFIG_ERROR, ); }); diff --git a/test/client/health-check.test.ts b/test/client/health-check.test.ts index 85bb382..483b56e 100644 --- a/test/client/health-check.test.ts +++ b/test/client/health-check.test.ts @@ -38,7 +38,7 @@ describe('Health check', () => { mockedAxios.create.mockReturnThis(); // restore isAxiosError mockedAxios.isAxiosError.mockImplementation( - (payload) => typeof payload === 'object' && payload.isAxiosError === true + (payload) => typeof payload === 'object' && payload.isAxiosError === true, ); client = new Client(clientOps); diff --git a/test/client/token-exchage.test.ts b/test/client/token-exchage.test.ts index 0416376..984085d 100644 --- a/test/client/token-exchage.test.ts +++ b/test/client/token-exchage.test.ts @@ -4,7 +4,7 @@ // SPDX-License-Identifier: MIT import axios from 'axios'; -import jwt, { TokenExpiredError, NotBeforeError } from 'jsonwebtoken'; +import { errors, SignJWT, decodeJwt } from 'jose'; import { Client, DuoException, constants, util } from '../../src'; import { AxiosError } from '../../src/axios-error'; @@ -14,6 +14,7 @@ const clientOps = { apiHost: 'api-123456.duo.com', redirectUrl: 'https://redirect-example.com/callback', }; +const secret = new TextEncoder().encode(clientOps.clientSecret); const username = 'username'; const code = util.generateRandomString(20); const nonce = util.generateRandomString(20); @@ -21,9 +22,9 @@ const nonce = util.generateRandomString(20); jest.mock('axios'); const mockedAxios = axios as jest.Mocked; -const createIdToken = ( +const createIdToken = async ( removeKey: string | null = null, - changeVal: Record | null = null + changeVal: Record | null = null, ) => { const time = util.getTimeInSeconds(); @@ -45,12 +46,14 @@ const createIdToken = ( if (changeVal) payload = { ...payload, ...changeVal }; - return jwt.sign(payload, clientOps.clientSecret, { algorithm: constants.SIG_ALGORITHM }); + return await new SignJWT(payload) + .setProtectedHeader({ alg: constants.SIG_ALGORITHM }) + .sign(secret); }; -const createTokenResult = (token: string | null = null) => { +const createTokenResult = async (token: string | null = null) => { return { - id_token: token ?? createIdToken(), + id_token: token ?? (await createIdToken()), access_token: '12345678', expires_in: util.getTimeInSeconds(), token_type: 'Bearer', @@ -91,7 +94,7 @@ describe('Token Exchange', () => { it('should thrown when request has missing properties', async () => { expect.assertions(2); - const { id_token, ...rest } = createTokenResult(); + const { id_token, ...rest } = await createTokenResult(); const response = { data: rest }; mockedAxios.post.mockResolvedValue(response); @@ -121,8 +124,8 @@ describe('Token Exchange', () => { it('should thrown when request has bad nonce', async () => { expect.assertions(2); - const token = createIdToken(null, { nonce: util.generateRandomString(10) }); - const data = createTokenResult(token); + const token = await createIdToken(null, { nonce: util.generateRandomString(10) }); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); try { @@ -136,8 +139,8 @@ describe('Token Exchange', () => { it('should thrown when token has missing properties', async () => { expect.assertions(2); - const token = createIdToken('iss'); - const data = createTokenResult(token); + const token = await createIdToken('iss'); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); try { @@ -151,8 +154,8 @@ describe('Token Exchange', () => { it('should thrown when token has bad iss', async () => { expect.assertions(2); - const token = createIdToken(null, { iss: 'bad-iss' }); - const data = createTokenResult(token); + const token = await createIdToken(null, { iss: 'bad-iss' }); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); try { @@ -166,8 +169,10 @@ describe('Token Exchange', () => { it('should thrown when token has bad exp', async () => { expect.assertions(3); - const token = createIdToken(null, { exp: util.getTimeInSeconds() - constants.JWT_EXPIRATION }); - const data = createTokenResult(token); + const token = await createIdToken(null, { + exp: util.getTimeInSeconds() - constants.JWT_EXPIRATION, + }); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); try { @@ -175,15 +180,17 @@ describe('Token Exchange', () => { } catch (err: any) { expect(err).toBeInstanceOf(DuoException); expect(err.message).toBe(constants.JWT_DECODE_ERROR); - expect(err.inner.name).toBe('TokenExpiredError'); + expect(err.inner).toBeInstanceOf(errors.JWTExpired); } }); it('should thrown when token has bad nbf', async () => { expect.assertions(3); - const token = createIdToken(null, { nbf: util.getTimeInSeconds() + constants.JWT_EXPIRATION }); - const data = createTokenResult(token); + const token = await createIdToken(null, { + nbf: util.getTimeInSeconds() + constants.JWT_EXPIRATION, + }); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); try { @@ -191,15 +198,15 @@ describe('Token Exchange', () => { } catch (err: any) { expect(err).toBeInstanceOf(DuoException); expect(err.message).toBe(constants.JWT_DECODE_ERROR); - expect(err.inner.name).toBe('NotBeforeError'); + expect(err.inner).toBeInstanceOf(errors.JWTClaimValidationFailed); } }); it('should thrown when token has bad aud', async () => { expect.assertions(2); - const token = createIdToken(null, { aud: 'bad-aud' }); - const data = createTokenResult(token); + const token = await createIdToken(null, { aud: 'bad-aud' }); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); try { @@ -213,8 +220,8 @@ describe('Token Exchange', () => { it('should thrown when token has bad username', async () => { expect.assertions(2); - const token = createIdToken(null, { preferred_username: 'bad-username' }); - const data = createTokenResult(token); + const token = await createIdToken(null, { preferred_username: 'bad-username' }); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); try { @@ -228,8 +235,10 @@ describe('Token Exchange', () => { it('should thrown when token expired', async () => { expect.assertions(3); - const token = createIdToken(null, { exp: util.getTimeInSeconds() - constants.JWT_LEEWAY * 2 }); - const data = createTokenResult(token); + const token = await createIdToken(null, { + exp: util.getTimeInSeconds() - constants.JWT_LEEWAY * 2, + }); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); try { @@ -237,15 +246,15 @@ describe('Token Exchange', () => { } catch (err: any) { expect(err).toBeInstanceOf(DuoException); expect(err.message).toBe(constants.JWT_DECODE_ERROR); - expect(err.inner).toBeInstanceOf(TokenExpiredError); + expect(err.inner).toBeInstanceOf(errors.JWTExpired); } }); it('should thrown when token is missing exp', async () => { expect.assertions(2); - const token = createIdToken('exp'); - const data = createTokenResult(token); + const token = await createIdToken('exp'); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); try { @@ -257,25 +266,27 @@ describe('Token Exchange', () => { }); it('should allow small clock skew', async () => { - const token = createIdToken(null, { exp: util.getTimeInSeconds() - constants.JWT_LEEWAY / 2 }); - const data = createTokenResult(token); + const token = await createIdToken(null, { + exp: util.getTimeInSeconds() - constants.JWT_LEEWAY / 2, + }); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); const result = await client.exchangeAuthorizationCodeFor2FAResult(code, username); - expect(result).toEqual(jwt.decode(token)); + expect(result).toEqual(decodeJwt(token)); }); it('should return successful exchange', async () => { expect.assertions(1); - const token = createIdToken(); - const data = createTokenResult(token); + const token = await createIdToken(); + const data = await createTokenResult(token); mockedAxios.post.mockResolvedValue({ data }); const result = await client.exchangeAuthorizationCodeFor2FAResult(code, username); - expect(result).toEqual(jwt.decode(token)); + expect(result).toEqual(decodeJwt(token)); }); it('should thrown when http request failed (missing data)', async () => {