From e5aef3b384270efd159e1ead909bc037809ef494 Mon Sep 17 00:00:00 2001 From: Marco Montalbano Date: Tue, 2 Jul 2024 16:21:44 +0200 Subject: [PATCH] feat: validate expiration on jwtVerify --- packages/js-auth/README.md | 4 ++- packages/js-auth/src/errors/TokenError.ts | 6 ++++ .../js-auth/src/errors/TokenExpiredError.ts | 6 ++++ packages/js-auth/src/index.ts | 3 ++ packages/js-auth/src/jwtDecode.spec.ts | 5 ++- packages/js-auth/src/jwtDecode.ts | 3 +- packages/js-auth/src/jwtVerify.spec.ts | 32 ++++++++++++++++--- packages/js-auth/src/jwtVerify.ts | 22 ++++++++++--- 8 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 packages/js-auth/src/errors/TokenError.ts create mode 100644 packages/js-auth/src/errors/TokenExpiredError.ts diff --git a/packages/js-auth/README.md b/packages/js-auth/README.md index 3317cf4..fedc33f 100644 --- a/packages/js-auth/README.md +++ b/packages/js-auth/README.md @@ -315,7 +315,9 @@ const auth = await authenticate('client_credentials', { scope: 'market:code:europe' }) -const decodedJWT = await jwtVerify(auth.accessToken) +const decodedJWT = await jwtVerify(auth.accessToken, { + ignoreExpiration: true +}) if (jwtIsSalesChannel(decodedJWT.payload)) { console.log('organization slug is', decodedJWT.payload.organization.slug) diff --git a/packages/js-auth/src/errors/TokenError.ts b/packages/js-auth/src/errors/TokenError.ts new file mode 100644 index 0000000..89f7673 --- /dev/null +++ b/packages/js-auth/src/errors/TokenError.ts @@ -0,0 +1,6 @@ +export class TokenError extends Error { + constructor(message: string) { + super(message) + this.name = 'TokenError' + } +} diff --git a/packages/js-auth/src/errors/TokenExpiredError.ts b/packages/js-auth/src/errors/TokenExpiredError.ts new file mode 100644 index 0000000..0893a5c --- /dev/null +++ b/packages/js-auth/src/errors/TokenExpiredError.ts @@ -0,0 +1,6 @@ +export class TokenExpiredError extends Error { + constructor() { + super('Token expired') + this.name = 'TokenExpiredError' + } +} diff --git a/packages/js-auth/src/index.ts b/packages/js-auth/src/index.ts index eb6d044..c3d72fc 100644 --- a/packages/js-auth/src/index.ts +++ b/packages/js-auth/src/index.ts @@ -26,3 +26,6 @@ export type { RevokeOptions, RevokeReturn } from './types/index.js' + +export { TokenError } from './errors/TokenError.js' +export { TokenExpiredError } from './errors/TokenExpiredError.js' diff --git a/packages/js-auth/src/jwtDecode.spec.ts b/packages/js-auth/src/jwtDecode.spec.ts index f2cb0fe..6e8e548 100644 --- a/packages/js-auth/src/jwtDecode.spec.ts +++ b/packages/js-auth/src/jwtDecode.spec.ts @@ -1,9 +1,12 @@ +import { TokenError } from './errors/TokenError.js' import { jwtDecode } from './jwtDecode.js' describe('jwtDecode', () => { it('should throw when the access token is not valid.', () => { const accessToken = 'hello-world' - expect(() => jwtDecode(accessToken)).toThrowError('Invalid JWT format') + + expect(() => jwtDecode(accessToken)).toThrow(TokenError) + expect(() => jwtDecode(accessToken)).toThrow('Invalid token format') }) it('should throw when the access token is malformed.', () => { diff --git a/packages/js-auth/src/jwtDecode.ts b/packages/js-auth/src/jwtDecode.ts index 6b77fd1..4ae19e8 100644 --- a/packages/js-auth/src/jwtDecode.ts +++ b/packages/js-auth/src/jwtDecode.ts @@ -1,3 +1,4 @@ +import { TokenError } from './errors/TokenError.js' import { decodeBase64URLSafe } from './utils/base64.js' /** @@ -10,7 +11,7 @@ export function jwtDecode(accessToken: string): CommerceLayerJWT { const [encodedHeader, encodedPayload, signature] = `${accessToken}`.split('.') if (encodedHeader == null || encodedPayload == null || signature == null) { - throw new Error('Invalid JWT format') + throw new TokenError('Invalid token format') } return { diff --git a/packages/js-auth/src/jwtVerify.spec.ts b/packages/js-auth/src/jwtVerify.spec.ts index 14ae5e2..55f8e02 100644 --- a/packages/js-auth/src/jwtVerify.spec.ts +++ b/packages/js-auth/src/jwtVerify.spec.ts @@ -1,15 +1,25 @@ import jwt from 'jsonwebtoken' +import { TokenExpiredError } from './errors/TokenExpiredError.js' import { jwtDecode } from './jwtDecode.js' import { jwtVerify } from './jwtVerify.js' import { encodeBase64URLSafe } from './utils/base64.js' +import { TokenError } from './errors/TokenError.js' describe('jwtVerify', () => { + it('should throw when token expired.', async () => { + void expect(async () => await jwtVerify(accessToken)).rejects.toThrow( + TokenExpiredError + ) + }) + it('should be able to verify a JWT.', async () => { const jsonwebtokenDecoded = jwt.decode(accessToken, { complete: true }) - const verification = await jwtVerify(accessToken) + const verification = await jwtVerify(accessToken, { + ignoreExpiration: true + }) expect(verification).toStrictEqual(jsonwebtokenDecoded) }) @@ -26,7 +36,11 @@ describe('jwtVerify', () => { signature ].join('.') - expect(await jwtVerify(newAccessToken)).toStrictEqual(decodedJWT) + expect( + await jwtVerify(newAccessToken, { + ignoreExpiration: true + }) + ).toStrictEqual(decodedJWT) }) it('should reject when the payload has been changed', async () => { @@ -44,8 +58,18 @@ describe('jwtVerify', () => { ].join('.') void expect( - async () => await jwtVerify(newAccessToken) - ).rejects.toThrowError('Invalid signature') + async () => + await jwtVerify(newAccessToken, { + ignoreExpiration: true + }) + ).rejects.toThrow(TokenError) + + void expect( + async () => + await jwtVerify(newAccessToken, { + ignoreExpiration: true + }) + ).rejects.toThrow('Invalid signature') }) }) }) diff --git a/packages/js-auth/src/jwtVerify.ts b/packages/js-auth/src/jwtVerify.ts index 0495030..bbb61f3 100644 --- a/packages/js-auth/src/jwtVerify.ts +++ b/packages/js-auth/src/jwtVerify.ts @@ -1,3 +1,5 @@ +import { TokenError } from './errors/TokenError.js' +import { TokenExpiredError } from './errors/TokenExpiredError.js' import { jwtDecode, type CommerceLayerJWT } from './jwtDecode.js' import { decodeBase64URLSafe } from './utils/base64.js' @@ -7,14 +9,21 @@ import { decodeBase64URLSafe } from './utils/base64.js' */ export async function jwtVerify( accessToken: string, - options: JwtVerifyOptions = {} + { ignoreExpiration = false, domain }: JwtVerifyOptions = {} ): Promise { const decodedJWT = jwtDecode(accessToken) - const jsonWebKey = await getJsonWebKey(decodedJWT.header.kid, options) + const jsonWebKey = await getJsonWebKey(decodedJWT.header.kid, { + domain, + ignoreExpiration + }) if (jsonWebKey == null) { - throw new Error('Invalid token "kid"') + throw new TokenError('Invalid token "kid"') + } + + if (!ignoreExpiration && Date.now() >= decodedJWT.payload.exp * 1000) { + throw new TokenExpiredError() } const algorithm: RsaHashedImportParams = { @@ -48,7 +57,7 @@ export async function jwtVerify( ) if (!isValid) { - throw new Error('Invalid signature') + throw new TokenError('Invalid signature') } return decodedJWT @@ -61,6 +70,11 @@ interface JwtVerifyOptions { * The Commerce Layer's domain. */ domain?: string + /** + * Do not validate the token expiration when set to `true`. + * @default false + */ + ignoreExpiration?: boolean } /**