@naturalcycles/nodejs-lib 15.119.0 → 15.121.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.
@@ -5,7 +5,7 @@ import { _deepCopy, _filterNullishValues, _sortObject } from '@naturalcycles/js-
5
5
  import { _substringBefore } from '@naturalcycles/js-lib/string';
6
6
  import { _objectAssign, _typeCast, JWT_REGEX } from '@naturalcycles/js-lib/types';
7
7
  import { _inspect } from '../../string/inspect.js';
8
- import { BASE64URL_REGEX, COUNTRY_CODE_REGEX, CURRENCY_REGEX, IPV4_REGEX, IPV6_REGEX, LANGUAGE_TAG_REGEX, SEMVER_REGEX, SLUG_REGEX, URL_REGEX, UUID_REGEX, } from '../regexes.js';
8
+ import { BASE64URL_REGEX, COUNTRY_CODE_REGEX, CURRENCY_REGEX, IPV4_REGEX, IPV6_REGEX, LANGUAGE_TAG_REGEX, SEMVER4_REGEX, SEMVER_REGEX, SLUG_REGEX, URL_REGEX, UUID_REGEX, } from '../regexes.js';
9
9
  import { TIMEZONES } from '../timezones.js';
10
10
  import { AjvValidationError } from './ajvValidationError.js';
11
11
  import { getAjv } from './getAjv.js';
@@ -623,9 +623,20 @@ export class JString extends JBuilder {
623
623
  slug() {
624
624
  return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' });
625
625
  }
626
+ /**
627
+ * Validates the 3-part semver format: `major.minor.patch`, e.g `1.2.3`.
628
+ */
626
629
  semVer() {
627
630
  return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' });
628
631
  }
632
+ /**
633
+ * Validates the 4-part version format: `major.minor.patch.build`, e.g `1.2.3.4`.
634
+ *
635
+ * Not a semver by the spec, but is used by e.g .NET and some mobile app versions.
636
+ */
637
+ semVer4() {
638
+ return this.regex(SEMVER4_REGEX, { msg: 'is not a valid 4-part semver format' });
639
+ }
629
640
  languageTag() {
630
641
  return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' });
631
642
  }
@@ -17,5 +17,6 @@ export declare const IPV6_REGEX: RegExp;
17
17
  export declare const LANGUAGE_TAG_REGEX: RegExp;
18
18
  export declare const MAC_ADDRESS_REGEX: RegExp;
19
19
  export declare const SEMVER_REGEX: RegExp;
20
+ export declare const SEMVER4_REGEX: RegExp;
20
21
  export declare const SLUG_REGEX: RegExp;
21
22
  export declare const URL_REGEX: RegExp;
@@ -22,6 +22,8 @@ export const IPV6_REGEX = /^((([0-9a-fA-F]{1,4}:){7}([0-9a-fA-F]{1,4}|:))|(([0-9
22
22
  export const LANGUAGE_TAG_REGEX = /^[a-z]{2}(-[A-Z]{2})?$/;
23
23
  export const MAC_ADDRESS_REGEX = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/;
24
24
  export const SEMVER_REGEX = /^[0-9]+\.[0-9]+\.[0-9]+$/;
25
+ // 4-part version (major.minor.patch.build)
26
+ export const SEMVER4_REGEX = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/;
25
27
  export const SLUG_REGEX = /^[a-z0-9-]+$/;
26
28
  // URL regex based on `ajv-formats`, but without flags for JSON Schema compatibility.
27
29
  // Uses [a-zA-Z] instead of [a-z] with i flag. Simplified to not require unicode flag.
package/package.json CHANGED
@@ -1,15 +1,13 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.119.0",
4
+ "version": "15.121.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@standard-schema/spec": "^1",
8
- "@types/jsonwebtoken": "^9",
9
8
  "ajv": "^8",
10
9
  "ansis": "^4",
11
10
  "jose": "^6",
12
- "jsonwebtoken": "^9",
13
11
  "lru-cache": "^11",
14
12
  "tinyglobby": "^0.2",
15
13
  "tslib": "^2",
package/src/jwt/index.ts CHANGED
@@ -1,2 +1 @@
1
1
  export * from './jwt.service.js'
2
- export * from './jwt.service2.js'
@@ -1,23 +1,47 @@
1
+ import { createPrivateKey, createPublicKey } from 'node:crypto'
2
+ import type { KeyObject } from 'node:crypto'
1
3
  import type { ErrorData } from '@naturalcycles/js-lib/error'
2
4
  import { _assert } from '@naturalcycles/js-lib/error/assert.js'
3
- import { _errorDataAppend } from '@naturalcycles/js-lib/error/error.util.js'
4
- import type { AnyObject, JWTString } from '@naturalcycles/js-lib/types'
5
- import type { Algorithm, JwtHeader, SignOptions, VerifyOptions } from 'jsonwebtoken'
6
- import jsonwebtoken from 'jsonwebtoken'
5
+ import { _errorDataAppend, AppError } from '@naturalcycles/js-lib/error/error.util.js'
6
+ import type {
7
+ AnyObject,
8
+ JWTString,
9
+ NumberOfSeconds,
10
+ UnixTimestamp,
11
+ } from '@naturalcycles/js-lib/types'
12
+ import { decodeJwt, decodeProtectedHeader, errors, jwtVerify, SignJWT } from 'jose'
7
13
  import type { AjvSchema, JSchema } from '../validation/ajv/jSchema.js'
8
14
 
9
- export { jsonwebtoken }
10
- export type { Algorithm, JwtHeader, SignOptions, VerifyOptions }
15
+ /**
16
+ * Asymmetric JWS algorithms supported by JWTService.
17
+ */
18
+ export type JWTAlgorithm =
19
+ | 'ES256'
20
+ | 'ES384'
21
+ | 'ES512'
22
+ | 'RS256'
23
+ | 'RS384'
24
+ | 'RS512'
25
+ | 'PS256'
26
+ | 'PS384'
27
+ | 'PS512'
28
+ | 'EdDSA'
11
29
 
12
- export interface JWTServiceCfg {
30
+ export interface JWTServiceCfg<T extends AnyObject = AnyObject> {
13
31
  /**
14
32
  * Public key is required to Verify incoming tokens.
15
33
  * Optional if you only want to Decode or Sign.
34
+ *
35
+ * PEM string/Buffer. Both SPKI ("PUBLIC KEY") and a private key PEM
36
+ * (public key is derived from it) are accepted.
16
37
  */
17
38
  publicKey?: string | Buffer
18
39
  /**
19
40
  * Private key is required to Sign (create) outgoing tokens.
20
41
  * Optional if you only want to Decode or Verify.
42
+ *
43
+ * PEM string/Buffer. Both PKCS8 ("PRIVATE KEY") and SEC1 ("EC PRIVATE KEY",
44
+ * as generated by `openssl ecparam`) are accepted.
21
45
  */
22
46
  privateKey?: string | Buffer
23
47
 
@@ -25,39 +49,151 @@ export interface JWTServiceCfg {
25
49
  * Recommended: ES256
26
50
  * Keys (private/public) should be generated using proper settings
27
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.
28
55
  */
29
- algorithm: Algorithm
56
+ algorithm: JWTAlgorithm
30
57
 
31
58
  /**
32
- * If provided - will be applied to every Sign operation.
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.)
33
68
  */
34
- signOptions?: SignOptions
69
+ verifyAlgorithms?: JWTAlgorithm[]
70
+
71
+ /**
72
+ * If provided - payloads are validated against it on every Sign/Verify/Decode.
73
+ * Can be overridden per-call via `opt.schema`.
74
+ *
75
+ * Note: on Verify/Decode the schema is applied to the raw JWT payload, which includes
76
+ * the standard claims (exp/nbf/iss/aud/sub/iat/jti) if they were set on Sign.
77
+ * Declare (or allow) them in the schema, otherwise a strict schema will
78
+ * strip them from the returned payload (default AjvSchema behavior: removeAdditional).
79
+ */
80
+ schema?: JSchema<T, any> | AjvSchema<T>
35
81
 
36
82
  /**
37
83
  * If provided - will be applied to every Sign operation.
84
+ * Absolute-timestamp options (expiresAt/notBefore/issuedAt) are excluded,
85
+ * as they would be fixed to the same moment for all tokens signed by this service.
86
+ */
87
+ signOptions?: Omit<JWTSignOptions, 'expiresAt' | 'notBefore' | 'issuedAt' | 'schema'>
88
+
89
+ /**
90
+ * If provided - will be applied to every Verify operation.
38
91
  */
39
- verifyOptions?: VerifyOptions
92
+ verifyOptions?: Omit<JWTVerifyOptions, 'schema' | 'publicKey'>
40
93
 
41
94
  /**
42
- * If set - errors thrown from this service will be extended
95
+ * If set - JWTErrors thrown from this service will be extended
43
96
  * with this errorData (in err.data)
44
97
  */
45
98
  errorData?: ErrorData
46
99
  }
47
100
 
48
- // todo: define JWTError and list possible options
49
- // jwt expired (TokenExpiredError)
50
- // jwt invalid
51
- // jwt token is empty
101
+ export interface JWTSignOptions<T extends AnyObject = AnyObject> {
102
+ /**
103
+ * Sets the `exp` claim, as an absolute UnixTimestamp.
104
+ * Convenient to build with LocalTime, e.g:
105
+ * `localTime.now().plus(30, 'minute').unix`
106
+ *
107
+ * Required, to protect from accidentally issuing never-expiring tokens.
108
+ * Pass `null` to explicitly sign a token without expiration.
109
+ */
110
+ expiresAt: UnixTimestamp | null
111
+ /**
112
+ * Sets the `nbf` (not before) claim, as an absolute UnixTimestamp.
113
+ */
114
+ notBefore?: UnixTimestamp
115
+ issuer?: string
116
+ audience?: string | string[]
117
+ subject?: string
118
+ jwtid?: string
119
+ /**
120
+ * Sets the `iat` claim, as an absolute UnixTimestamp (e.g `localTime.nowUnix()`).
121
+ * By default `iat` is NOT set (same as the legacy jsonwebtoken-based JWTService
122
+ * with its `noTimestamp: true` default).
123
+ */
124
+ issuedAt?: UnixTimestamp
125
+ /**
126
+ * Sets the `kid` (key id) header, required by some APIs (e.g Apple App Store Connect).
127
+ */
128
+ kid?: string
129
+ /**
130
+ * Overrides cfg.schema for this call.
131
+ */
132
+ schema?: JSchema<T, any> | AjvSchema<T>
133
+ }
134
+
135
+ export interface JWTVerifyOptions<T extends AnyObject = AnyObject> {
136
+ audience?: string | string[]
137
+ issuer?: string | string[]
138
+ subject?: string
139
+ /**
140
+ * Clock skew tolerance, in seconds.
141
+ */
142
+ clockTolerance?: NumberOfSeconds
143
+ /**
144
+ * Maximum allowed age of the token (based on its `iat` claim), in seconds.
145
+ */
146
+ maxTokenAge?: NumberOfSeconds
147
+ /**
148
+ * "Now" override, useful in tests.
149
+ */
150
+ now?: UnixTimestamp
151
+ requiredClaims?: string[]
152
+ /**
153
+ * Overrides cfg.schema for this call.
154
+ */
155
+ schema?: JSchema<T, any> | AjvSchema<T>
156
+ /**
157
+ * Overrides cfg.publicKey for this call,
158
+ * e.g when verifying tokens signed with different keys (kid-based).
159
+ */
160
+ publicKey?: string | Buffer
161
+ }
162
+
163
+ export interface JWTDecodeOptions<T extends AnyObject = AnyObject> {
164
+ /**
165
+ * Overrides cfg.schema for this call.
166
+ */
167
+ schema?: JSchema<T, any> | AjvSchema<T>
168
+ }
169
+
170
+ export interface JWTHeader {
171
+ alg: string
172
+ typ?: string
173
+ kid?: string
174
+ [k: string]: unknown
175
+ }
176
+
177
+ export interface JWTDecoded<T extends AnyObject> {
178
+ header: JWTHeader
179
+ payload: T
180
+ signature: string
181
+ }
52
182
 
53
183
  /**
54
- * @deprecated use JWTService2 (jose-based) instead.
55
- * Tokens are wire-compatible in both directions, only the API differs
56
- * (async sign/verify, normalized JWTError).
57
- * JWTService2 will be renamed to JWTService when this class is dropped.
184
+ * Wraps the `jose` library, exposing an implementation-agnostic API:
185
+ * no jose types, options or errors leak out of this service.
186
+ * All errors are normalized into JWTError subclasses
187
+ * (JWTExpiredError / JWTNotYetValidError / JWTInvalidError), matchable with `instanceof`.
188
+ *
189
+ * Successor of the legacy jsonwebtoken-based JWTService. Tokens are wire-compatible
190
+ * in both directions, so the two services can be swapped freely for the same key pair.
191
+ * Difference is that Sign and Verify are async (jose is WebCrypto-based, which is Promise-only),
192
+ * while Decode remains sync (pure base64url/JSON parsing, no crypto).
58
193
  *
59
- * Wraps popular `jsonwebtoken` library.
60
194
  * You should create one instance of JWTService for each pair of private/public key.
195
+ * Providing cfg.schema types the service to its payload and validates it
196
+ * on every Sign/Verify/Decode; it can be overridden per-call via opt.schema.
61
197
  *
62
198
  * Generate key pair like this.
63
199
  * Please note that parameters should be different for different algorithms.
@@ -66,82 +202,286 @@ export interface JWTServiceCfg {
66
202
  * openssl ecparam -name prime256v1 -genkey -noout -out key.pem
67
203
  * openssl ec -in key.pem -pubout > key.pub.pem
68
204
  */
69
- export class JWTService {
70
- constructor(public cfg: JWTServiceCfg) {}
71
-
72
- sign<T extends AnyObject>(
73
- payload: T,
74
- schema?: JSchema<T, any> | AjvSchema<T>,
75
- opt: SignOptions = {},
76
- ): JWTString {
205
+ export class JWTService<T extends AnyObject = AnyObject> {
206
+ private privateKey?: KeyObject
207
+ private publicKey?: KeyObject
208
+
209
+ constructor(public cfg: JWTServiceCfg<T>) {
210
+ // KeyObjects are parsed synchronously via node:crypto (also accepts SEC1 PEMs,
211
+ // which jose's own async importers reject), keeping the constructor sync.
212
+ if (cfg.privateKey) this.privateKey = createPrivateKey(cfg.privateKey)
213
+ if (cfg.publicKey) this.publicKey = createPublicKey(cfg.publicKey)
214
+ }
215
+
216
+ async sign<TT extends T = T>(payload: TT, opt: JWTSignOptions<TT>): Promise<JWTString> {
77
217
  _assert(
78
- this.cfg.privateKey,
79
- 'JWTService: privateKey is required to be able to verify, but not provided',
218
+ this.privateKey,
219
+ 'JWTService: privateKey is required to be able to sign, but not provided',
220
+ this.cfg.errorData,
80
221
  )
81
222
 
82
- schema?.validate(payload)
83
-
84
- return jsonwebtoken.sign(payload, this.cfg.privateKey, {
85
- algorithm: this.cfg.algorithm,
86
- noTimestamp: true,
223
+ const { expiresAt, notBefore, issuer, audience, subject, jwtid, issuedAt, kid, schema } = {
87
224
  ...this.cfg.signOptions,
88
225
  ...opt,
226
+ }
227
+
228
+ ;(schema || this.cfg.schema)?.validate(payload)
229
+
230
+ // `kid: undefined` is dropped by JSON serialization, keeping the header unchanged when not set
231
+ const jwt = new SignJWT(payload).setProtectedHeader({
232
+ alg: this.cfg.algorithm,
233
+ typ: 'JWT',
234
+ kid,
89
235
  })
236
+ if (expiresAt !== null) jwt.setExpirationTime(expiresAt)
237
+ if (notBefore !== undefined) jwt.setNotBefore(notBefore)
238
+ if (issuer) jwt.setIssuer(issuer)
239
+ if (audience) jwt.setAudience(audience)
240
+ if (subject) jwt.setSubject(subject)
241
+ if (jwtid) jwt.setJti(jwtid)
242
+ if (issuedAt !== undefined) jwt.setIssuedAt(issuedAt)
243
+
244
+ return await jwt.sign(this.privateKey)
90
245
  }
91
246
 
92
- verify<T extends AnyObject>(
93
- token: JWTString,
94
- schema?: JSchema<T, any> | AjvSchema<T>,
95
- opt: VerifyOptions = {},
96
- publicKey?: string, // allows to override public key
97
- ): T {
247
+ async verify<TT extends T = T>(token: JWTString, opt: JWTVerifyOptions<TT> = {}): Promise<TT> {
248
+ const { now, schema, publicKey, ...joseOpt } = {
249
+ ...this.cfg.verifyOptions,
250
+ ...opt,
251
+ }
252
+
253
+ const key = publicKey ? createPublicKey(publicKey) : this.publicKey
98
254
  _assert(
99
- this.cfg.publicKey,
255
+ key,
100
256
  'JWTService: publicKey is required to be able to verify, but not provided',
257
+ this.cfg.errorData,
101
258
  )
102
259
 
260
+ let data: TT
261
+
262
+ try {
263
+ const { payload } = await jwtVerify(token, key, {
264
+ algorithms: this.cfg.verifyAlgorithms || [this.cfg.algorithm],
265
+ ...joseOpt,
266
+ currentDate: now === undefined ? undefined : new Date(now * 1000),
267
+ })
268
+ data = payload as TT
269
+ } catch (err) {
270
+ throw this.normalizeError(err)
271
+ }
272
+
273
+ this.validate(data, schema)
274
+
275
+ return data
276
+ }
277
+
278
+ /**
279
+ * Tries to Verify the token, returning an [error, payload] tuple instead of throwing:
280
+ * - [null, payload] - the token verified, payload can be trusted
281
+ * - [error, null] - verification failed
282
+ *
283
+ * Same contract as tryToVerifyOrDecode, but without the unverified-decode fallback:
284
+ * the payload is only returned when the token verified.
285
+ */
286
+ async tryToVerify<TT extends T = T>(
287
+ token: JWTString,
288
+ opt: JWTVerifyOptions<TT> = {},
289
+ ): Promise<[err: null, payload: TT] | [err: Error, payload: null]> {
290
+ try {
291
+ return [null, await this.verify<TT>(token, opt)]
292
+ } catch (err) {
293
+ return [err as Error, null]
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Tries to Verify the token, falling back to unverified Decode on failure,
299
+ * so the caller can "peek" into the token's content even when it doesn't verify.
300
+ *
301
+ * Returns an [error, payload] tuple - ALWAYS check the error first:
302
+ * - [null, payload] - the token verified, payload can be trusted
303
+ * - [error, payload] - verification failed, but the token could still be decoded.
304
+ * When the error is JWTExpiredError/JWTNotYetValidError, the signature was already
305
+ * verified and only the time-claim check failed, so the payload is trustworthy
306
+ * (just expired/not yet valid). For any other error the payload is completely
307
+ * UNVERIFIED - never trust it, only peek (e.g to log/report the claimed identity).
308
+ * - [error, null] - the token could not even be decoded
309
+ *
310
+ * The decode fallback is raw: opt.schema/cfg.schema are only applied on the Verify path.
311
+ */
312
+ async tryToVerifyOrDecode<TT extends T = T>(
313
+ token: JWTString,
314
+ opt: JWTVerifyOptions<TT> = {},
315
+ ): Promise<[err: null, payload: TT] | [err: Error, payload: TT | null]> {
316
+ const [verifyError, payload] = await this.tryToVerify<TT>(token, opt)
317
+ if (!verifyError) return [null, payload]
318
+
319
+ // Expired/not-yet-valid errors already carry the (signature-verified) payload
320
+ if (verifyError instanceof JWTExpiredError || verifyError instanceof JWTNotYetValidError) {
321
+ return [verifyError, verifyError.payload as TT]
322
+ }
323
+
103
324
  try {
104
- const data = jsonwebtoken.verify(token, publicKey || this.cfg.publicKey, {
105
- algorithms: [this.cfg.algorithm],
106
- ...this.cfg.verifyOptions,
107
- ...opt,
108
- }) as T
325
+ return [verifyError, jwtDecode<TT>(token).payload]
326
+ } catch {
327
+ return [verifyError, null]
328
+ }
329
+ }
109
330
 
110
- schema?.validate(data)
331
+ decode<TT extends T = T>(token: JWTString, opt: JWTDecodeOptions<TT> = {}): JWTDecoded<TT> {
332
+ let decoded: JWTDecoded<TT>
111
333
 
112
- return data
334
+ try {
335
+ decoded = jwtDecode<TT>(token)
113
336
  } catch (err) {
114
337
  if (this.cfg.errorData) {
115
- _errorDataAppend(err, {
116
- ...this.cfg.errorData,
117
- })
338
+ _errorDataAppend(err, this.cfg.errorData)
118
339
  }
119
340
  throw err
120
341
  }
342
+
343
+ this.validate(decoded.payload, opt.schema)
344
+
345
+ return decoded
121
346
  }
122
347
 
123
- decode<T extends AnyObject>(
124
- token: JWTString,
125
- schema?: JSchema<T, any> | AjvSchema<T>,
126
- ): {
127
- header: JwtHeader
128
- payload: T
129
- signature: string
130
- } {
131
- const data = jsonwebtoken.decode(token, {
132
- complete: true,
133
- }) as {
134
- header: JwtHeader
135
- payload: T
136
- signature: string
137
- } | null
138
-
139
- _assert(data?.payload, 'invalid token, decoded value is empty', {
140
- ...this.cfg.errorData,
141
- })
348
+ /**
349
+ * Schema-validation errors on Verify/Decode are extended with cfg.errorData:
350
+ * a token with a non-conforming payload is as unauthorized as an invalid one.
351
+ * (On Sign the payload comes from own code, not from user input, so a validation
352
+ * error there indicates a programming error and is thrown as-is.)
353
+ */
354
+ private validate<TT extends T>(payload: TT, schema?: JSchema<TT, any> | AjvSchema<TT>): void {
355
+ try {
356
+ ;(schema || this.cfg.schema)?.validate(payload)
357
+ } catch (err) {
358
+ if (this.cfg.errorData) {
359
+ _errorDataAppend(err, this.cfg.errorData)
360
+ }
361
+ throw err
362
+ }
363
+ }
142
364
 
143
- schema?.validate(data.payload)
365
+ /**
366
+ * jose errors are normalized into JWTError subclasses (extended with cfg.errorData).
367
+ * Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
368
+ * indicate a programming error and are passed through as-is.
369
+ */
370
+ private normalizeError(err: unknown): Error {
371
+ const { errorData } = this.cfg
144
372
 
145
- return data
373
+ if (err instanceof errors.JWTExpired) {
374
+ return new JWTExpiredError(err.message, errorData, { payload: err.payload })
375
+ }
376
+ if (err instanceof errors.JWTClaimValidationFailed && err.claim === 'nbf') {
377
+ return new JWTNotYetValidError(err.message, errorData, { payload: err.payload })
378
+ }
379
+ if (err instanceof errors.JOSEError) {
380
+ return new JWTInvalidError(err.message, errorData)
381
+ }
382
+ if (this.cfg.verifyAlgorithms && err instanceof TypeError) {
383
+ // With multiple verifyAlgorithms, a token/key algorithm mismatch is reachable
384
+ // by untrusted input, and jose reports it as TypeError - treat it as an invalid token
385
+ return new JWTInvalidError(err.message, errorData)
386
+ }
387
+ return err as Error
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Decodes a JWT without verifying its signature (no key needed) - never trust the result.
393
+ * Standalone version of JWTService.decode, for when there's no service instance at hand
394
+ * (no schema validation, no errorData extension).
395
+ *
396
+ * Throws JWTInvalidError if the token cannot be decoded.
397
+ */
398
+ export function jwtDecode<T extends AnyObject>(token: JWTString): JWTDecoded<T> {
399
+ let header: JWTHeader
400
+ let payload: T
401
+
402
+ try {
403
+ header = decodeProtectedHeader(token) as JWTHeader
404
+ payload = decodeJwt(token)
405
+ } catch (err) {
406
+ // The underlying message is folded in, as it carries the only specific detail
407
+ throw new JWTInvalidError(`invalid token, unable to decode: ${(err as Error).message}`)
408
+ }
409
+
410
+ return {
411
+ header,
412
+ payload,
413
+ signature: token.split('.')[2]!,
414
+ }
415
+ }
416
+
417
+ export type JWTErrorCode = 'JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID'
418
+
419
+ export interface JWTErrorData extends ErrorData {
420
+ code: JWTErrorCode
421
+ }
422
+
423
+ /**
424
+ * Thrown by JWTService on any Verify/Decode failure, always as one of its subclasses:
425
+ * - JWTExpiredError - `exp` claim check failed
426
+ * - JWTNotYetValidError - `nbf` claim check failed
427
+ * - JWTInvalidError - anything else (malformed token, wrong signature, other claim mismatches)
428
+ *
429
+ * Match errors with `instanceof`, e.g `err instanceof JWTExpiredError`.
430
+ * `data.code` carries the same distinction ('JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID'),
431
+ * for when the error crosses a serialization boundary (e.g ErrorObject over HTTP)
432
+ * where `instanceof` no longer works.
433
+ *
434
+ * The underlying jose error is deliberately NOT preserved in `cause`: everything useful
435
+ * from it is already carried (message is copied, the failed claim is the subclass,
436
+ * `payload` is exposed where trustworthy), and jose nests the raw JWT payload into its own
437
+ * `cause`, which would otherwise survive ErrorObject serialization and leak into logs.
438
+ */
439
+ export class JWTError extends AppError<JWTErrorData> {
440
+ constructor(message: string, data: JWTErrorData) {
441
+ // `new.target.name` makes subclasses report their own name
442
+ super(message, data, { name: new.target.name })
443
+ }
444
+ }
445
+
446
+ /**
447
+ * The token is well-formed and its signature is valid, but the `exp` claim check failed.
448
+ *
449
+ * `payload` carries the decoded token payload: signature is verified before claims are
450
+ * checked, so the payload is trustworthy - it's just expired.
451
+ * (Note: it's the raw JWT payload, incl. standard claims; cfg.schema is not applied to it.)
452
+ */
453
+ export class JWTExpiredError extends JWTError {
454
+ readonly payload: AnyObject
455
+
456
+ constructor(message: string, data: ErrorData = {}, opt: { payload?: AnyObject } = {}) {
457
+ super(message, { ...data, code: 'JWT_EXPIRED' })
458
+ this.payload = opt.payload || {}
459
+ }
460
+ }
461
+
462
+ /**
463
+ * The token is well-formed and its signature is valid, but the `nbf` claim check failed.
464
+ *
465
+ * `payload` carries the decoded token payload: signature is verified before claims are
466
+ * checked, so the payload is trustworthy - it's just not valid yet.
467
+ * (Note: it's the raw JWT payload, incl. standard claims; cfg.schema is not applied to it.)
468
+ */
469
+ export class JWTNotYetValidError extends JWTError {
470
+ readonly payload: AnyObject
471
+
472
+ constructor(message: string, data: ErrorData = {}, opt: { payload?: AnyObject } = {}) {
473
+ super(message, { ...data, code: 'JWT_NOT_YET_VALID' })
474
+ this.payload = opt.payload || {}
475
+ }
476
+ }
477
+
478
+ /**
479
+ * The token could not be trusted: malformed token, wrong signature,
480
+ * or a claim mismatch other than exp/nbf (e.g issuer/audience).
481
+ * No payload is exposed - nothing from such a token should be used.
482
+ */
483
+ export class JWTInvalidError extends JWTError {
484
+ constructor(message: string, data: ErrorData = {}) {
485
+ super(message, { ...data, code: 'JWT_INVALID' })
146
486
  }
147
487
  }
@@ -36,6 +36,7 @@ import {
36
36
  IPV4_REGEX,
37
37
  IPV6_REGEX,
38
38
  LANGUAGE_TAG_REGEX,
39
+ SEMVER4_REGEX,
39
40
  SEMVER_REGEX,
40
41
  SLUG_REGEX,
41
42
  URL_REGEX,
@@ -831,10 +832,22 @@ export class JString<
831
832
  return this.regex(SLUG_REGEX, { msg: 'is not a valid slug format' })
832
833
  }
833
834
 
835
+ /**
836
+ * Validates the 3-part semver format: `major.minor.patch`, e.g `1.2.3`.
837
+ */
834
838
  semVer(): this {
835
839
  return this.regex(SEMVER_REGEX, { msg: 'is not a valid semver format' })
836
840
  }
837
841
 
842
+ /**
843
+ * Validates the 4-part version format: `major.minor.patch.build`, e.g `1.2.3.4`.
844
+ *
845
+ * Not a semver by the spec, but is used by e.g .NET and some mobile app versions.
846
+ */
847
+ semVer4(): this {
848
+ return this.regex(SEMVER4_REGEX, { msg: 'is not a valid 4-part semver format' })
849
+ }
850
+
838
851
  languageTag(): this {
839
852
  return this.regex(LANGUAGE_TAG_REGEX, { msg: 'is not a valid language format' })
840
853
  }
@@ -23,6 +23,8 @@ export const IPV6_REGEX =
23
23
  export const LANGUAGE_TAG_REGEX = /^[a-z]{2}(-[A-Z]{2})?$/
24
24
  export const MAC_ADDRESS_REGEX = /^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/
25
25
  export const SEMVER_REGEX = /^[0-9]+\.[0-9]+\.[0-9]+$/
26
+ // 4-part version (major.minor.patch.build)
27
+ export const SEMVER4_REGEX = /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/
26
28
  export const SLUG_REGEX = /^[a-z0-9-]+$/
27
29
  // URL regex based on `ajv-formats`, but without flags for JSON Schema compatibility.
28
30
  // Uses [a-zA-Z] instead of [a-z] with i flag. Simplified to not require unicode flag.