@@ -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 */
350344export 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 */
384385export 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