Skip to content

Commit 8ac16e2

Browse files
feat: dedicated error classes for jwt
1 parent e7f00d6 commit 8ac16e2

2 files changed

Lines changed: 121 additions & 62 deletions

File tree

packages/nodejs-lib/src/jwt/jwt.service2.test.ts

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ import { fs2 } from '../fs/fs2.js'
88
import { testDir } from '../test/paths.cnst.js'
99
import { j } from '../validation/ajv/index.js'
1010
import { JWTService } from './jwt.service.js'
11-
import { JWTError, JWTService2, jwtDecode } from './jwt.service2.js'
11+
import {
12+
JWTError,
13+
JWTExpiredError,
14+
JWTInvalidError,
15+
JWTNotYetValidError,
16+
JWTService2,
17+
jwtDecode,
18+
} from './jwt.service2.js'
1219

1320
const privateKey = fs2.readText(`${testDir}/demoPrivateKey.pem`)
1421

@@ -97,24 +104,42 @@ test('expiresAt', async () => {
97104
expect(exp).toBe(expiresAt)
98105
})
99106

100-
test('expired token throws JWT_EXPIRED', async () => {
107+
test('expired token throws JWTExpiredError', async () => {
101108
const expiredToken = await jwtService2.sign(data1, {
102109
expiresAt: localTime.now().minus(2, 'minute').unix,
103110
})
104111

105-
const err = await pExpectedError(jwtService2.verify(expiredToken), JWTError)
106-
expect(err.data.code).toBe('JWT_EXPIRED')
107-
expect(err.cause).toBeDefined()
112+
const err = await pExpectedError(jwtService2.verify(expiredToken), JWTExpiredError)
113+
expect(err).toBeInstanceOf(JWTError) // subclasses remain instanceof the base class
114+
expect(err.name).toBe('JWTExpiredError')
115+
expect(err.data.code).toBe('JWT_EXPIRED') // kept for serialized contexts (ErrorObject over HTTP)
116+
// The jose error is not chained as cause: it would leak the raw payload into serialized logs
117+
expect(err.cause).toBeUndefined()
118+
// Signature was verified before the exp check, so the payload is exposed (raw, incl. claims)
119+
expect(err.payload).toStrictEqual({ ...data1, exp: expect.any(Number) })
108120
})
109121

110-
test('not-yet-valid token throws JWT_NOT_YET_VALID', async () => {
122+
test('not-yet-valid token throws JWTNotYetValidError', async () => {
111123
const token1 = await jwtService2.sign(data1, {
112124
expiresAt: null,
113125
notBefore: localTime.now().plus(1, 'hour').unix,
114126
})
115127

116-
const err = await pExpectedError(jwtService2.verify(token1), JWTError)
128+
const err = await pExpectedError(jwtService2.verify(token1), JWTNotYetValidError)
129+
expect(err).toBeInstanceOf(JWTError)
117130
expect(err.data.code).toBe('JWT_NOT_YET_VALID')
131+
expect(err.payload).toStrictEqual({ ...data1, nbf: expect.any(Number) })
132+
})
133+
134+
test('error subclasses are directly constructible, e.g for test mocks', () => {
135+
const err = new JWTExpiredError('jwt expired')
136+
expect(err.name).toBe('JWTExpiredError')
137+
expect(err.data.code).toBe('JWT_EXPIRED')
138+
expect(err.payload).toEqual({})
139+
140+
const err2 = new JWTInvalidError('invalid token')
141+
expect(err2.name).toBe('JWTInvalidError')
142+
expect(err2.data.code).toBe('JWT_INVALID')
118143
})
119144

120145
test('verifyOptions: now and clockTolerance', async () => {
@@ -134,23 +159,21 @@ test('verifyOptions: now and clockTolerance', async () => {
134159
expect(verified2).toMatchObject(data1)
135160

136161
// without tolerance it still fails
137-
const err = await pExpectedError(jwtService2.verify(expiredToken), JWTError)
138-
expect(err.data.code).toBe('JWT_EXPIRED')
162+
await pExpectedError(jwtService2.verify(expiredToken), JWTExpiredError)
139163
})
140164

141-
test('malformed and tampered tokens throw JWT_INVALID', async () => {
165+
test('malformed and tampered tokens throw JWTInvalidError', async () => {
142166
const token1 = await jwtService2.sign(data1, { expiresAt: null })
143167
const malformedToken = token1.slice(1)
144168
const tamperedToken = token1.slice(0, -3) + 'AAA'
145169

146-
const err1 = await pExpectedError(jwtService2.verify(malformedToken), JWTError)
170+
const err1 = await pExpectedError(jwtService2.verify(malformedToken), JWTInvalidError)
147171
expect(err1.data.code).toBe('JWT_INVALID')
148172

149-
const err2 = await pExpectedError(jwtService2.verify(tamperedToken), JWTError)
150-
expect(err2.data.code).toBe('JWT_INVALID')
173+
await pExpectedError(jwtService2.verify(tamperedToken), JWTInvalidError)
151174

152175
expect(() => jwtService2.decode(malformedToken)).toThrowErrorMatchingInlineSnapshot(
153-
`[JWTError: invalid token, unable to decode]`,
176+
`[JWTInvalidError: invalid token, unable to decode: Invalid Token or Protected Header formatting]`,
154177
)
155178

156179
// tamperedToken has corrupted signature, but Decode doesn't use it
@@ -172,7 +195,7 @@ test('cfg.errorData is merged into JWTError data', async () => {
172195
expiresAt: localTime.now().minus(2, 'minute').unix,
173196
})
174197

175-
const err = await pExpectedError(service.verify(expiredToken), JWTError)
198+
const err = await pExpectedError(service.verify(expiredToken), JWTExpiredError)
176199
expect(err.data).toMatchObject({
177200
code: 'JWT_EXPIRED',
178201
backendResponseStatusCode: 401,
@@ -201,12 +224,8 @@ test('standard claim signOptions', async () => {
201224
})
202225
expect(payload['iat']).toBe(issuedAt)
203226

204-
// mismatched issuer should fail with JWT_INVALID
205-
const err = await pExpectedError(
206-
noSchemaService.verify(token1, { issuer: 'other-issuer' }),
207-
JWTError,
208-
)
209-
expect(err.data.code).toBe('JWT_INVALID')
227+
// mismatched issuer should fail with JWTInvalidError
228+
await pExpectedError(noSchemaService.verify(token1, { issuer: 'other-issuer' }), JWTInvalidError)
210229
})
211230

212231
test('cfg.signOptions are applied to every sign', async () => {
@@ -290,18 +309,13 @@ test('verifyAlgorithms: one verifier accepting multiple key types', async () =>
290309

291310
// Key/algorithm mismatch: the token's `alg` cannot steer verification
292311
// onto a key of the wrong type - allowed algorithms are narrowed to fit the key
293-
const err1 = await pExpectedError(
294-
multiVerifier.verify(ecToken, { publicKey: rsaPublicPem }),
295-
JWTError,
296-
)
297-
expect(err1.data.code).toBe('JWT_INVALID')
312+
await pExpectedError(multiVerifier.verify(ecToken, { publicKey: rsaPublicPem }), JWTInvalidError)
298313

299314
// Single-algorithm service rejects tokens of any other algorithm
300-
const err2 = await pExpectedError(
315+
await pExpectedError(
301316
noSchemaService.verify(rsaToken, { publicKey: rsaPublicPem }),
302-
JWTError,
317+
JWTInvalidError,
303318
)
304-
expect(err2.data.code).toBe('JWT_INVALID')
305319
})
306320

307321
test('kid header option', async () => {
@@ -320,6 +334,6 @@ test('standalone jwtDecode', async () => {
320334
expect(decoded.payload).toStrictEqual(data1)
321335
expect(decoded.signature).toBeDefined()
322336

323-
const err = _expectedError(() => jwtDecode(token1.slice(1)), JWTError)
337+
const err = _expectedError(() => jwtDecode(token1.slice(1)), JWTInvalidError)
324338
expect(err.data.code).toBe('JWT_INVALID')
325339
})

packages/nodejs-lib/src/jwt/jwt.service2.ts

Lines changed: 77 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export interface JWTService2Cfg<T extends AnyObject = AnyObject> {
6262
* (e.g a per-kid key set mixing EC and RSA keys). Keep the list as narrow as possible.
6363
*
6464
* The token's `alg` header chooses among these, but only within what the
65-
* verification key can serve: a token/key algorithm mismatch fails with JWT_INVALID.
65+
* verification key can serve: a token/key algorithm mismatch fails with JWTInvalidError.
6666
* (All JWTAlgorithms are asymmetric, so the classic algorithm-confusion attacks
6767
* don't apply regardless.)
6868
*/
@@ -182,7 +182,8 @@ export interface JWTDecoded<T extends AnyObject> {
182182
/**
183183
* Wraps the `jose` library, exposing an implementation-agnostic API:
184184
* no jose types, options or errors leak out of this service.
185-
* All errors are normalized into JWTError with a stable `data.code`.
185+
* All errors are normalized into JWTError subclasses
186+
* (JWTExpiredError / JWTNotYetValidError / JWTInvalidError), matchable with `instanceof`.
186187
*
187188
* Successor of JWTService (jsonwebtoken-based). Tokens are wire-compatible
188189
* in both directions, so the two services can be swapped freely for the same key pair.
@@ -308,35 +309,28 @@ export class JWTService2<T extends AnyObject = AnyObject> {
308309
}
309310

310311
/**
311-
* jose errors are normalized into JWTError (extended with cfg.errorData).
312+
* jose errors are normalized into JWTError subclasses (extended with cfg.errorData).
312313
* Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
313314
* indicate a programming error and are passed through as-is.
314315
*/
315316
private normalizeError(err: unknown): Error {
316-
let code: JWTErrorCode
317+
const { errorData } = this.cfg
317318

318319
if (err instanceof errors.JWTExpired) {
319-
code = 'JWT_EXPIRED'
320-
} else if (err instanceof errors.JWTClaimValidationFailed && err.claim === 'nbf') {
321-
code = 'JWT_NOT_YET_VALID'
322-
} else if (err instanceof errors.JOSEError) {
323-
code = 'JWT_INVALID'
324-
} else if (this.cfg.verifyAlgorithms && err instanceof TypeError) {
320+
return new JWTExpiredError(err.message, errorData, { payload: err.payload })
321+
}
322+
if (err instanceof errors.JWTClaimValidationFailed && err.claim === 'nbf') {
323+
return new JWTNotYetValidError(err.message, errorData, { payload: err.payload })
324+
}
325+
if (err instanceof errors.JOSEError) {
326+
return new JWTInvalidError(err.message, errorData)
327+
}
328+
if (this.cfg.verifyAlgorithms && err instanceof TypeError) {
325329
// With multiple verifyAlgorithms, a token/key algorithm mismatch is reachable
326330
// by untrusted input, and jose reports it as TypeError - treat it as an invalid token
327-
code = 'JWT_INVALID'
328-
} else {
329-
return err as Error
331+
return new JWTInvalidError(err.message, errorData)
330332
}
331-
332-
return new JWTError(
333-
err.message,
334-
{
335-
...this.cfg.errorData,
336-
code,
337-
},
338-
{ cause: err },
339-
)
333+
return err as Error
340334
}
341335
}
342336

@@ -345,7 +339,7 @@ export class JWTService2<T extends AnyObject = AnyObject> {
345339
* Standalone version of JWTService2.decode, for when there's no service instance at hand
346340
* (no schema validation, no errorData extension).
347341
*
348-
* Throws JWTError with code JWT_INVALID if the token cannot be decoded.
342+
* Throws JWTInvalidError if the token cannot be decoded.
349343
*/
350344
export function jwtDecode<T extends AnyObject>(token: JWTString): JWTDecoded<T> {
351345
let header: JWTHeader
@@ -355,7 +349,8 @@ export function jwtDecode<T extends AnyObject>(token: JWTString): JWTDecoded<T>
355349
header = decodeProtectedHeader(token) as JWTHeader
356350
payload = decodeJwt(token)
357351
} catch (err) {
358-
throw new JWTError('invalid token, unable to decode', { code: 'JWT_INVALID' }, { cause: err })
352+
// The underlying message is folded in, as it carries the only specific detail
353+
throw new JWTInvalidError(`invalid token, unable to decode: ${(err as Error).message}`)
359354
}
360355

361356
return {
@@ -372,17 +367,67 @@ export interface JWTErrorData extends ErrorData {
372367
}
373368

374369
/**
375-
* Thrown by JWTService2 on any Verify/Decode failure.
370+
* Thrown by JWTService2 on any Verify/Decode failure, always as one of its subclasses:
371+
* - JWTExpiredError - `exp` claim check failed
372+
* - JWTNotYetValidError - `nbf` claim check failed
373+
* - JWTInvalidError - anything else (malformed token, wrong signature, other claim mismatches)
376374
*
377-
* `data.code` is stable and implementation-agnostic:
378-
* - JWT_EXPIRED - `exp` claim check failed
379-
* - JWT_NOT_YET_VALID - `nbf` claim check failed
380-
* - JWT_INVALID - anything else (malformed token, wrong signature, other claim mismatches)
375+
* Match errors with `instanceof`, e.g `err instanceof JWTExpiredError`.
376+
* `data.code` carries the same distinction ('JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID'),
377+
* for when the error crosses a serialization boundary (e.g ErrorObject over HTTP)
378+
* where `instanceof` no longer works.
381379
*
382-
* The original underlying error is preserved in `cause`.
380+
* The underlying jose error is deliberately NOT preserved in `cause`: everything useful
381+
* from it is already carried (message is copied, the failed claim is the subclass,
382+
* `payload` is exposed where trustworthy), and jose nests the raw JWT payload into its own
383+
* `cause`, which would otherwise survive ErrorObject serialization and leak into logs.
383384
*/
384385
export class JWTError extends AppError<JWTErrorData> {
385-
constructor(message: string, data: JWTErrorData, opt?: { cause?: any }) {
386-
super(message, data, { ...opt, name: 'JWTError' })
386+
constructor(message: string, data: JWTErrorData) {
387+
// `new.target.name` makes subclasses report their own name
388+
super(message, data, { name: new.target.name })
389+
}
390+
}
391+
392+
/**
393+
* The token is well-formed and its signature is valid, but the `exp` claim check failed.
394+
*
395+
* `payload` carries the decoded token payload: signature is verified before claims are
396+
* checked, so the payload is trustworthy - it's just expired.
397+
* (Note: it's the raw JWT payload, incl. standard claims; cfg.schema is not applied to it.)
398+
*/
399+
export class JWTExpiredError extends JWTError {
400+
readonly payload: AnyObject
401+
402+
constructor(message: string, data: ErrorData = {}, opt: { payload?: AnyObject } = {}) {
403+
super(message, { ...data, code: 'JWT_EXPIRED' })
404+
this.payload = opt.payload || {}
405+
}
406+
}
407+
408+
/**
409+
* The token is well-formed and its signature is valid, but the `nbf` claim check failed.
410+
*
411+
* `payload` carries the decoded token payload: signature is verified before claims are
412+
* checked, so the payload is trustworthy - it's just not valid yet.
413+
* (Note: it's the raw JWT payload, incl. standard claims; cfg.schema is not applied to it.)
414+
*/
415+
export class JWTNotYetValidError extends JWTError {
416+
readonly payload: AnyObject
417+
418+
constructor(message: string, data: ErrorData = {}, opt: { payload?: AnyObject } = {}) {
419+
super(message, { ...data, code: 'JWT_NOT_YET_VALID' })
420+
this.payload = opt.payload || {}
421+
}
422+
}
423+
424+
/**
425+
* The token could not be trusted: malformed token, wrong signature,
426+
* or a claim mismatch other than exp/nbf (e.g issuer/audience).
427+
* No payload is exposed - nothing from such a token should be used.
428+
*/
429+
export class JWTInvalidError extends JWTError {
430+
constructor(message: string, data: ErrorData = {}) {
431+
super(message, { ...data, code: 'JWT_INVALID' })
387432
}
388433
}

0 commit comments

Comments
 (0)