@naturalcycles/nodejs-lib 15.116.2 → 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.
@@ -27,8 +27,23 @@ export interface JWTService2Cfg<T extends AnyObject = AnyObject> {
27
27
  * Recommended: ES256
28
28
  * Keys (private/public) should be generated using proper settings
29
29
  * that fit the used Algorithm.
30
+ *
31
+ * Used for Sign, and (unless `verifyAlgorithms` is set) it's also
32
+ * the only algorithm accepted on Verify.
30
33
  */
31
34
  algorithm: JWTAlgorithm;
35
+ /**
36
+ * JWS algorithms accepted on Verify. Defaults to `[algorithm]`.
37
+ *
38
+ * Only needed when one verifier must accept keys of different types
39
+ * (e.g a per-kid key set mixing EC and RSA keys). Keep the list as narrow as possible.
40
+ *
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 JWTInvalidError.
43
+ * (All JWTAlgorithms are asymmetric, so the classic algorithm-confusion attacks
44
+ * don't apply regardless.)
45
+ */
46
+ verifyAlgorithms?: JWTAlgorithm[];
32
47
  /**
33
48
  * If provided - payloads are validated against it on every Sign/Verify/Decode.
34
49
  * Can be overridden per-call via `opt.schema`.
@@ -134,7 +149,8 @@ export interface JWTDecoded<T extends AnyObject> {
134
149
  /**
135
150
  * Wraps the `jose` library, exposing an implementation-agnostic API:
136
151
  * no jose types, options or errors leak out of this service.
137
- * 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`.
138
154
  *
139
155
  * Successor of JWTService (jsonwebtoken-based). Tokens are wire-compatible
140
156
  * in both directions, so the two services can be swapped freely for the same key pair.
@@ -168,7 +184,7 @@ export declare class JWTService2<T extends AnyObject = AnyObject> {
168
184
  */
169
185
  private validate;
170
186
  /**
171
- * jose errors are normalized into JWTError (extended with cfg.errorData).
187
+ * jose errors are normalized into JWTError subclasses (extended with cfg.errorData).
172
188
  * Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
173
189
  * indicate a programming error and are passed through as-is.
174
190
  */
@@ -179,7 +195,7 @@ export declare class JWTService2<T extends AnyObject = AnyObject> {
179
195
  * Standalone version of JWTService2.decode, for when there's no service instance at hand
180
196
  * (no schema validation, no errorData extension).
181
197
  *
182
- * Throws JWTError with code JWT_INVALID if the token cannot be decoded.
198
+ * Throws JWTInvalidError if the token cannot be decoded.
183
199
  */
184
200
  export declare function jwtDecode<T extends AnyObject>(token: JWTString): JWTDecoded<T>;
185
201
  export type JWTErrorCode = 'JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID';
@@ -187,17 +203,55 @@ export interface JWTErrorData extends ErrorData {
187
203
  code: JWTErrorCode;
188
204
  }
189
205
  /**
190
- * 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)
191
210
  *
192
- * `data.code` is stable and implementation-agnostic:
193
- * - JWT_EXPIRED - `exp` claim check failed
194
- * - JWT_NOT_YET_VALID - `nbf` claim check failed
195
- * - 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.
196
215
  *
197
- * 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.
198
220
  */
199
221
  export declare class JWTError extends AppError<JWTErrorData> {
200
- constructor(message: string, data: JWTErrorData, opt?: {
201
- 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;
202
235
  });
203
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;
248
+ });
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.
@@ -75,7 +76,7 @@ export class JWTService2 {
75
76
  let data;
76
77
  try {
77
78
  const { payload } = await jwtVerify(token, key, {
78
- algorithms: [this.cfg.algorithm],
79
+ algorithms: this.cfg.verifyAlgorithms || [this.cfg.algorithm],
79
80
  ...joseOpt,
80
81
  currentDate: now === undefined ? undefined : new Date(now * 1000),
81
82
  });
@@ -120,28 +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 {
139
- return err;
139
+ if (this.cfg.verifyAlgorithms && err instanceof TypeError) {
140
+ // With multiple verifyAlgorithms, a token/key algorithm mismatch is reachable
141
+ // by untrusted input, and jose reports it as TypeError - treat it as an invalid token
142
+ return new JWTInvalidError(err.message, errorData);
140
143
  }
141
- return new JWTError(err.message, {
142
- ...this.cfg.errorData,
143
- code,
144
- }, { cause: err });
144
+ return err;
145
145
  }
146
146
  }
147
147
  /**
@@ -149,7 +149,7 @@ export class JWTService2 {
149
149
  * Standalone version of JWTService2.decode, for when there's no service instance at hand
150
150
  * (no schema validation, no errorData extension).
151
151
  *
152
- * Throws JWTError with code JWT_INVALID if the token cannot be decoded.
152
+ * Throws JWTInvalidError if the token cannot be decoded.
153
153
  */
154
154
  export function jwtDecode(token) {
155
155
  let header;
@@ -159,7 +159,8 @@ export function jwtDecode(token) {
159
159
  payload = decodeJwt(token);
160
160
  }
161
161
  catch (err) {
162
- 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}`);
163
164
  }
164
165
  return {
165
166
  header,
@@ -168,17 +169,62 @@ export function jwtDecode(token) {
168
169
  };
169
170
  }
170
171
  /**
171
- * 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)
172
176
  *
173
- * `data.code` is stable and implementation-agnostic:
174
- * - JWT_EXPIRED - `exp` claim check failed
175
- * - JWT_NOT_YET_VALID - `nbf` claim check failed
176
- * - 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.
177
181
  *
178
- * 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.
179
186
  */
180
187
  export class JWTError extends AppError {
181
- constructor(message, data, opt) {
182
- 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' });
183
229
  }
184
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.2",
4
+ "version": "15.117.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@standard-schema/spec": "^1",
@@ -49,9 +49,25 @@ export interface JWTService2Cfg<T extends AnyObject = AnyObject> {
49
49
  * Recommended: ES256
50
50
  * Keys (private/public) should be generated using proper settings
51
51
  * that fit the used Algorithm.
52
+ *
53
+ * Used for Sign, and (unless `verifyAlgorithms` is set) it's also
54
+ * the only algorithm accepted on Verify.
52
55
  */
53
56
  algorithm: JWTAlgorithm
54
57
 
58
+ /**
59
+ * JWS algorithms accepted on Verify. Defaults to `[algorithm]`.
60
+ *
61
+ * Only needed when one verifier must accept keys of different types
62
+ * (e.g a per-kid key set mixing EC and RSA keys). Keep the list as narrow as possible.
63
+ *
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 JWTInvalidError.
66
+ * (All JWTAlgorithms are asymmetric, so the classic algorithm-confusion attacks
67
+ * don't apply regardless.)
68
+ */
69
+ verifyAlgorithms?: JWTAlgorithm[]
70
+
55
71
  /**
56
72
  * If provided - payloads are validated against it on every Sign/Verify/Decode.
57
73
  * Can be overridden per-call via `opt.schema`.
@@ -166,7 +182,8 @@ export interface JWTDecoded<T extends AnyObject> {
166
182
  /**
167
183
  * Wraps the `jose` library, exposing an implementation-agnostic API:
168
184
  * no jose types, options or errors leak out of this service.
169
- * 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`.
170
187
  *
171
188
  * Successor of JWTService (jsonwebtoken-based). Tokens are wire-compatible
172
189
  * in both directions, so the two services can be swapped freely for the same key pair.
@@ -243,7 +260,7 @@ export class JWTService2<T extends AnyObject = AnyObject> {
243
260
 
244
261
  try {
245
262
  const { payload } = await jwtVerify(token, key, {
246
- algorithms: [this.cfg.algorithm],
263
+ algorithms: this.cfg.verifyAlgorithms || [this.cfg.algorithm],
247
264
  ...joseOpt,
248
265
  currentDate: now === undefined ? undefined : new Date(now * 1000),
249
266
  })
@@ -292,31 +309,28 @@ export class JWTService2<T extends AnyObject = AnyObject> {
292
309
  }
293
310
 
294
311
  /**
295
- * jose errors are normalized into JWTError (extended with cfg.errorData).
312
+ * jose errors are normalized into JWTError subclasses (extended with cfg.errorData).
296
313
  * Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
297
314
  * indicate a programming error and are passed through as-is.
298
315
  */
299
316
  private normalizeError(err: unknown): Error {
300
- let code: JWTErrorCode
317
+ const { errorData } = this.cfg
301
318
 
302
319
  if (err instanceof errors.JWTExpired) {
303
- code = 'JWT_EXPIRED'
304
- } else if (err instanceof errors.JWTClaimValidationFailed && err.claim === 'nbf') {
305
- code = 'JWT_NOT_YET_VALID'
306
- } else if (err instanceof errors.JOSEError) {
307
- code = 'JWT_INVALID'
308
- } else {
309
- return err as Error
320
+ return new JWTExpiredError(err.message, errorData, { payload: err.payload })
310
321
  }
311
-
312
- return new JWTError(
313
- (err as Error).message,
314
- {
315
- ...this.cfg.errorData,
316
- code,
317
- },
318
- { cause: err },
319
- )
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) {
329
+ // With multiple verifyAlgorithms, a token/key algorithm mismatch is reachable
330
+ // by untrusted input, and jose reports it as TypeError - treat it as an invalid token
331
+ return new JWTInvalidError(err.message, errorData)
332
+ }
333
+ return err as Error
320
334
  }
321
335
  }
322
336
 
@@ -325,7 +339,7 @@ export class JWTService2<T extends AnyObject = AnyObject> {
325
339
  * Standalone version of JWTService2.decode, for when there's no service instance at hand
326
340
  * (no schema validation, no errorData extension).
327
341
  *
328
- * Throws JWTError with code JWT_INVALID if the token cannot be decoded.
342
+ * Throws JWTInvalidError if the token cannot be decoded.
329
343
  */
330
344
  export function jwtDecode<T extends AnyObject>(token: JWTString): JWTDecoded<T> {
331
345
  let header: JWTHeader
@@ -333,9 +347,10 @@ export function jwtDecode<T extends AnyObject>(token: JWTString): JWTDecoded<T>
333
347
 
334
348
  try {
335
349
  header = decodeProtectedHeader(token) as JWTHeader
336
- payload = decodeJwt(token) as T
350
+ payload = decodeJwt(token)
337
351
  } catch (err) {
338
- 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}`)
339
354
  }
340
355
 
341
356
  return {
@@ -352,17 +367,67 @@ export interface JWTErrorData extends ErrorData {
352
367
  }
353
368
 
354
369
  /**
355
- * 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)
356
374
  *
357
- * `data.code` is stable and implementation-agnostic:
358
- * - JWT_EXPIRED - `exp` claim check failed
359
- * - JWT_NOT_YET_VALID - `nbf` claim check failed
360
- * - 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.
361
379
  *
362
- * 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.
363
384
  */
364
385
  export class JWTError extends AppError<JWTErrorData> {
365
- constructor(message: string, data: JWTErrorData, opt?: { cause?: any }) {
366
- 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' })
367
432
  }
368
433
  }