Skip to content

Commit

Permalink
fix: swap jsonwebtoken with jose
Browse files Browse the repository at this point in the history
- support esm/cjs treeshakable without transient dependencies to support various envs
  • Loading branch information
lukashroch committed Jan 18, 2025
1 parent a2f247d commit d504b32
Show file tree
Hide file tree
Showing 10 changed files with 129 additions and 1,112 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion example/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1,024 changes: 15 additions & 1,009 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
63 changes: 31 additions & 32 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -38,7 +37,7 @@ export class Client {

private clientId: string;

private clientSecret: string;
private clientSecret: Uint8Array;

private apiHost: string;

Expand All @@ -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;
Expand Down Expand Up @@ -93,7 +92,7 @@ export class Client {

try {
new URL(redirectUrl);
} catch (err) {
} catch {
throw new DuoException(constants.PARSING_CONFIG_ERROR);
}
}
Expand Down Expand Up @@ -124,19 +123,21 @@ export class Client {
* @returns {string}
* @memberof Client
*/
private createJwtPayload(audience: string): string {
private async createJwtPayload(audience: string): Promise<string> {
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;
}

/**
Expand All @@ -151,22 +152,18 @@ export class Client {
private async verifyToken<T>(token: string): Promise<T> {
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<T>(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);
}
}

/**
Expand Down Expand Up @@ -208,7 +205,7 @@ export class Client {
*/
async healthCheck(): Promise<HealthCheckResponse> {
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,
Expand All @@ -217,7 +214,7 @@ export class Client {
try {
const { data } = await this.axios.post<HealthCheckResponse>(
this.HEALTH_CHECK_ENDPOINT,
new URLSearchParams(request)
new URLSearchParams(request),
);
const { stat } = data;

Expand All @@ -237,7 +234,7 @@ export class Client {
* @returns {string}
* @memberof Client
*/
createAuthUrl(username: string, state: string): string {
async createAuthUrl(username: string, state: string): Promise<string> {
if (!username) throw new DuoException(constants.DUO_USERNAME_ERROR);

if (
Expand All @@ -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',
Expand All @@ -287,14 +286,14 @@ export class Client {
async exchangeAuthorizationCodeFor2FAResult(
code: string,
username: string,
nonce: string | null = null
nonce: string | null = null,
): Promise<TokenResponsePayload> {
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,
Expand All @@ -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 */
Expand Down
2 changes: 1 addition & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 27 additions & 25 deletions test/client/auth-url.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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');

Expand All @@ -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');

Expand All @@ -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);
}
});
});
12 changes: 6 additions & 6 deletions test/client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});

Expand All @@ -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,
);
});

Expand All @@ -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,
);
});

Expand All @@ -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,
);
});

Expand Down
2 changes: 1 addition & 1 deletion test/client/health-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit d504b32

Please sign in to comment.