@naturalcycles/nodejs-lib 15.116.3 → 15.117.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,7 +39,7 @@ export interface JWTService2Cfg<T extends AnyObject = AnyObject> {
39
39
  * (e.g a per-kid key set mixing EC and RSA keys). Keep the list as narrow as possible.
40
40
  *
41
41
  * The token's `alg` header chooses among these, but only within what the
42
- * verification key can serve: a token/key algorithm mismatch fails with JWT_INVALID.
42
+ * verification key can serve: a token/key algorithm mismatch fails with JWTInvalidError.
43
43
  * (All JWTAlgorithms are asymmetric, so the classic algorithm-confusion attacks
44
44
  * don't apply regardless.)
45
45
  */
@@ -149,7 +149,8 @@ export interface JWTDecoded<T extends AnyObject> {
149
149
  /**
150
150
  * Wraps the `jose` library, exposing an implementation-agnostic API:
151
151
  * no jose types, options or errors leak out of this service.
152
- * All errors are normalized into JWTError with a stable `data.code`.
152
+ * All errors are normalized into JWTError subclasses
153
+ * (JWTExpiredError / JWTNotYetValidError / JWTInvalidError), matchable with `instanceof`.
153
154
  *
154
155
  * Successor of JWTService (jsonwebtoken-based). Tokens are wire-compatible
155
156
  * in both directions, so the two services can be swapped freely for the same key pair.
@@ -183,7 +184,7 @@ export declare class JWTService2<T extends AnyObject = AnyObject> {
183
184
  */
184
185
  private validate;
185
186
  /**
186
- * jose errors are normalized into JWTError (extended with cfg.errorData).
187
+ * jose errors are normalized into JWTError subclasses (extended with cfg.errorData).
187
188
  * Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
188
189
  * indicate a programming error and are passed through as-is.
189
190
  */
@@ -194,7 +195,7 @@ export declare class JWTService2<T extends AnyObject = AnyObject> {
194
195
  * Standalone version of JWTService2.decode, for when there's no service instance at hand
195
196
  * (no schema validation, no errorData extension).
196
197
  *
197
- * Throws JWTError with code JWT_INVALID if the token cannot be decoded.
198
+ * Throws JWTInvalidError if the token cannot be decoded.
198
199
  */
199
200
  export declare function jwtDecode<T extends AnyObject>(token: JWTString): JWTDecoded<T>;
200
201
  export type JWTErrorCode = 'JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID';
@@ -202,17 +203,55 @@ export interface JWTErrorData extends ErrorData {
202
203
  code: JWTErrorCode;
203
204
  }
204
205
  /**
205
- * Thrown by JWTService2 on any Verify/Decode failure.
206
+ * Thrown by JWTService2 on any Verify/Decode failure, always as one of its subclasses:
207
+ * - JWTExpiredError - `exp` claim check failed
208
+ * - JWTNotYetValidError - `nbf` claim check failed
209
+ * - JWTInvalidError - anything else (malformed token, wrong signature, other claim mismatches)
206
210
  *
207
- * `data.code` is stable and implementation-agnostic:
208
- * - JWT_EXPIRED - `exp` claim check failed
209
- * - JWT_NOT_YET_VALID - `nbf` claim check failed
210
- * - JWT_INVALID - anything else (malformed token, wrong signature, other claim mismatches)
211
+ * Match errors with `instanceof`, e.g `err instanceof JWTExpiredError`.
212
+ * `data.code` carries the same distinction ('JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID'),
213
+ * for when the error crosses a serialization boundary (e.g ErrorObject over HTTP)
214
+ * where `instanceof` no longer works.
211
215
  *
212
- * The original underlying error is preserved in `cause`.
216
+ * The underlying jose error is deliberately NOT preserved in `cause`: everything useful
217
+ * from it is already carried (message is copied, the failed claim is the subclass,
218
+ * `payload` is exposed where trustworthy), and jose nests the raw JWT payload into its own
219
+ * `cause`, which would otherwise survive ErrorObject serialization and leak into logs.
213
220
  */
214
221
  export declare class JWTError extends AppError<JWTErrorData> {
215
- constructor(message: string, data: JWTErrorData, opt?: {
216
- cause?: any;
222
+ constructor(message: string, data: JWTErrorData);
223
+ }
224
+ /**
225
+ * The token is well-formed and its signature is valid, but the `exp` claim check failed.
226
+ *
227
+ * `payload` carries the decoded token payload: signature is verified before claims are
228
+ * checked, so the payload is trustworthy - it's just expired.
229
+ * (Note: it's the raw JWT payload, incl. standard claims; cfg.schema is not applied to it.)
230
+ */
231
+ export declare class JWTExpiredError extends JWTError {
232
+ readonly payload: AnyObject;
233
+ constructor(message: string, data?: ErrorData, opt?: {
234
+ payload?: AnyObject;
235
+ });
236
+ }
237
+ /**
238
+ * The token is well-formed and its signature is valid, but the `nbf` claim check failed.
239
+ *
240
+ * `payload` carries the decoded token payload: signature is verified before claims are
241
+ * checked, so the payload is trustworthy - it's just not valid yet.
242
+ * (Note: it's the raw JWT payload, incl. standard claims; cfg.schema is not applied to it.)
243
+ */
244
+ export declare class JWTNotYetValidError extends JWTError {
245
+ readonly payload: AnyObject;
246
+ constructor(message: string, data?: ErrorData, opt?: {
247
+ payload?: AnyObject;
217
248
  });
218
249
  }
250
+ /**
251
+ * The token could not be trusted: malformed token, wrong signature,
252
+ * or a claim mismatch other than exp/nbf (e.g issuer/audience).
253
+ * No payload is exposed - nothing from such a token should be used.
254
+ */
255
+ export declare class JWTInvalidError extends JWTError {
256
+ constructor(message: string, data?: ErrorData);
257
+ }
@@ -5,7 +5,8 @@ import { decodeJwt, decodeProtectedHeader, errors, jwtVerify, SignJWT } from 'jo
5
5
  /**
6
6
  * Wraps the `jose` library, exposing an implementation-agnostic API:
7
7
  * no jose types, options or errors leak out of this service.
8
- * All errors are normalized into JWTError with a stable `data.code`.
8
+ * All errors are normalized into JWTError subclasses
9
+ * (JWTExpiredError / JWTNotYetValidError / JWTInvalidError), matchable with `instanceof`.
9
10
  *
10
11
  * Successor of JWTService (jsonwebtoken-based). Tokens are wire-compatible
11
12
  * in both directions, so the two services can be swapped freely for the same key pair.
@@ -120,33 +121,27 @@ export class JWTService2 {
120
121
  }
121
122
  }
122
123
  /**
123
- * jose errors are normalized into JWTError (extended with cfg.errorData).
124
+ * jose errors are normalized into JWTError subclasses (extended with cfg.errorData).
124
125
  * Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
125
126
  * indicate a programming error and are passed through as-is.
126
127
  */
127
128
  normalizeError(err) {
128
- let code;
129
+ const { errorData } = this.cfg;
129
130
  if (err instanceof errors.JWTExpired) {
130
- code = 'JWT_EXPIRED';
131
+ return new JWTExpiredError(err.message, errorData, { payload: err.payload });
131
132
  }
132
- else if (err instanceof errors.JWTClaimValidationFailed && err.claim === 'nbf') {
133
- code = 'JWT_NOT_YET_VALID';
133
+ if (err instanceof errors.JWTClaimValidationFailed && err.claim === 'nbf') {
134
+ return new JWTNotYetValidError(err.message, errorData, { payload: err.payload });
134
135
  }
135
- else if (err instanceof errors.JOSEError) {
136
- code = 'JWT_INVALID';
136
+ if (err instanceof errors.JOSEError) {
137
+ return new JWTInvalidError(err.message, errorData);
137
138
  }
138
- else if (this.cfg.verifyAlgorithms && err instanceof TypeError) {
139
+ if (this.cfg.verifyAlgorithms && err instanceof TypeError) {
139
140
  // With multiple verifyAlgorithms, a token/key algorithm mismatch is reachable
140
141
  // by untrusted input, and jose reports it as TypeError - treat it as an invalid token
141
- code = 'JWT_INVALID';
142
+ return new JWTInvalidError(err.message, errorData);
142
143
  }
143
- else {
144
- return err;
145
- }
146
- return new JWTError(err.message, {
147
- ...this.cfg.errorData,
148
- code,
149
- }, { cause: err });
144
+ return err;
150
145
  }
151
146
  }
152
147
  /**
@@ -154,7 +149,7 @@ export class JWTService2 {
154
149
  * Standalone version of JWTService2.decode, for when there's no service instance at hand
155
150
  * (no schema validation, no errorData extension).
156
151
  *
157
- * Throws JWTError with code JWT_INVALID if the token cannot be decoded.
152
+ * Throws JWTInvalidError if the token cannot be decoded.
158
153
  */
159
154
  export function jwtDecode(token) {
160
155
  let header;
@@ -164,7 +159,8 @@ export function jwtDecode(token) {
164
159
  payload = decodeJwt(token);
165
160
  }
166
161
  catch (err) {
167
- throw new JWTError('invalid token, unable to decode', { code: 'JWT_INVALID' }, { cause: err });
162
+ // The underlying message is folded in, as it carries the only specific detail
163
+ throw new JWTInvalidError(`invalid token, unable to decode: ${err.message}`);
168
164
  }
169
165
  return {
170
166
  header,
@@ -173,17 +169,62 @@ export function jwtDecode(token) {
173
169
  };
174
170
  }
175
171
  /**
176
- * Thrown by JWTService2 on any Verify/Decode failure.
172
+ * Thrown by JWTService2 on any Verify/Decode failure, always as one of its subclasses:
173
+ * - JWTExpiredError - `exp` claim check failed
174
+ * - JWTNotYetValidError - `nbf` claim check failed
175
+ * - JWTInvalidError - anything else (malformed token, wrong signature, other claim mismatches)
177
176
  *
178
- * `data.code` is stable and implementation-agnostic:
179
- * - JWT_EXPIRED - `exp` claim check failed
180
- * - JWT_NOT_YET_VALID - `nbf` claim check failed
181
- * - JWT_INVALID - anything else (malformed token, wrong signature, other claim mismatches)
177
+ * Match errors with `instanceof`, e.g `err instanceof JWTExpiredError`.
178
+ * `data.code` carries the same distinction ('JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID'),
179
+ * for when the error crosses a serialization boundary (e.g ErrorObject over HTTP)
180
+ * where `instanceof` no longer works.
182
181
  *
183
- * The original underlying error is preserved in `cause`.
182
+ * The underlying jose error is deliberately NOT preserved in `cause`: everything useful
183
+ * from it is already carried (message is copied, the failed claim is the subclass,
184
+ * `payload` is exposed where trustworthy), and jose nests the raw JWT payload into its own
185
+ * `cause`, which would otherwise survive ErrorObject serialization and leak into logs.
184
186
  */
185
187
  export class JWTError extends AppError {
186
- constructor(message, data, opt) {
187
- super(message, data, { ...opt, name: 'JWTError' });
188
+ constructor(message, data) {
189
+ // `new.target.name` makes subclasses report their own name
190
+ super(message, data, { name: new.target.name });
191
+ }
192
+ }
193
+ /**
194
+ * The token is well-formed and its signature is valid, but the `exp` claim check failed.
195
+ *
196
+ * `payload` carries the decoded token payload: signature is verified before claims are
197
+ * checked, so the payload is trustworthy - it's just expired.
198
+ * (Note: it's the raw JWT payload, incl. standard claims; cfg.schema is not applied to it.)
199
+ */
200
+ export class JWTExpiredError extends JWTError {
201
+ payload;
202
+ constructor(message, data = {}, opt = {}) {
203
+ super(message, { ...data, code: 'JWT_EXPIRED' });
204
+ this.payload = opt.payload || {};
205
+ }
206
+ }
207
+ /**
208
+ * The token is well-formed and its signature is valid, but the `nbf` claim check failed.
209
+ *
210
+ * `payload` carries the decoded token payload: signature is verified before claims are
211
+ * checked, so the payload is trustworthy - it's just not valid yet.
212
+ * (Note: it's the raw JWT payload, incl. standard claims; cfg.schema is not applied to it.)
213
+ */
214
+ export class JWTNotYetValidError extends JWTError {
215
+ payload;
216
+ constructor(message, data = {}, opt = {}) {
217
+ super(message, { ...data, code: 'JWT_NOT_YET_VALID' });
218
+ this.payload = opt.payload || {};
219
+ }
220
+ }
221
+ /**
222
+ * The token could not be trusted: malformed token, wrong signature,
223
+ * or a claim mismatch other than exp/nbf (e.g issuer/audience).
224
+ * No payload is exposed - nothing from such a token should be used.
225
+ */
226
+ export class JWTInvalidError extends JWTError {
227
+ constructor(message, data = {}) {
228
+ super(message, { ...data, code: 'JWT_INVALID' });
188
229
  }
189
230
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.116.3",
4
+ "version": "15.117.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@standard-schema/spec": "^1",
@@ -62,7 +62,7 @@ export interface JWTService2Cfg<T extends AnyObject = AnyObject> {
62
62
  * (e.g a per-kid key set mixing EC and RSA keys). Keep the list as narrow as possible.
63
63
  *
64
64
  * 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.
66
66
  * (All JWTAlgorithms are asymmetric, so the classic algorithm-confusion attacks
67
67
  * don't apply regardless.)
68
68
  */
@@ -182,7 +182,8 @@ export interface JWTDecoded<T extends AnyObject> {
182
182
  /**
183
183
  * Wraps the `jose` library, exposing an implementation-agnostic API:
184
184
  * 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`.
186
187
  *
187
188
  * Successor of JWTService (jsonwebtoken-based). Tokens are wire-compatible
188
189
  * 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> {
308
309
  }
309
310
 
310
311
  /**
311
- * jose errors are normalized into JWTError (extended with cfg.errorData).
312
+ * jose errors are normalized into JWTError subclasses (extended with cfg.errorData).
312
313
  * Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
313
314
  * indicate a programming error and are passed through as-is.
314
315
  */
315
316
  private normalizeError(err: unknown): Error {
316
- let code: JWTErrorCode
317
+ const { errorData } = this.cfg
317
318
 
318
319
  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) {
325
329
  // With multiple verifyAlgorithms, a token/key algorithm mismatch is reachable
326
330
  // 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)
330
332
  }
331
-
332
- return new JWTError(
333
- (err as Error).message,
334
- {
335
- ...this.cfg.errorData,
336
- code,
337
- },
338
- { cause: err },
339
- )
333
+ return err as Error
340
334
  }
341
335
  }
342
336
 
@@ -345,7 +339,7 @@ export class JWTService2<T extends AnyObject = AnyObject> {
345
339
  * Standalone version of JWTService2.decode, for when there's no service instance at hand
346
340
  * (no schema validation, no errorData extension).
347
341
  *
348
- * Throws JWTError with code JWT_INVALID if the token cannot be decoded.
342
+ * Throws JWTInvalidError if the token cannot be decoded.
349
343
  */
350
344
  export function jwtDecode<T extends AnyObject>(token: JWTString): JWTDecoded<T> {
351
345
  let header: JWTHeader
@@ -353,9 +347,10 @@ export function jwtDecode<T extends AnyObject>(token: JWTString): JWTDecoded<T>
353
347
 
354
348
  try {
355
349
  header = decodeProtectedHeader(token) as JWTHeader
356
- payload = decodeJwt(token) as T
350
+ payload = decodeJwt(token)
357
351
  } 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}`)
359
354
  }
360
355
 
361
356
  return {
@@ -372,17 +367,67 @@ export interface JWTErrorData extends ErrorData {
372
367
  }
373
368
 
374
369
  /**
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)
376
374
  *
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.
381
379
  *
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.
383
384
  */
384
385
  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' })
387
432
  }
388
433
  }