@naturalcycles/nodejs-lib 15.119.0 → 15.120.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.
@@ -1,486 +0,0 @@
1
- import { createPrivateKey, createPublicKey } from 'node:crypto'
2
- import type { KeyObject } from 'node:crypto'
3
- import type { ErrorData } from '@naturalcycles/js-lib/error'
4
- import { _assert } from '@naturalcycles/js-lib/error/assert.js'
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'
13
- import type { AjvSchema, JSchema } from '../validation/ajv/jSchema.js'
14
-
15
- /**
16
- * Asymmetric JWS algorithms supported by JWTService2.
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'
29
-
30
- export interface JWTService2Cfg<T extends AnyObject = AnyObject> {
31
- /**
32
- * Public key is required to Verify incoming tokens.
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.
37
- */
38
- publicKey?: string | Buffer
39
- /**
40
- * Private key is required to Sign (create) outgoing tokens.
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.
45
- */
46
- privateKey?: string | Buffer
47
-
48
- /**
49
- * Recommended: ES256
50
- * Keys (private/public) should be generated using proper settings
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.
55
- */
56
- algorithm: JWTAlgorithm
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
-
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>
81
-
82
- /**
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.
91
- */
92
- verifyOptions?: Omit<JWTVerifyOptions, 'schema' | 'publicKey'>
93
-
94
- /**
95
- * If set - JWTErrors thrown from this service will be extended
96
- * with this errorData (in err.data)
97
- */
98
- errorData?: ErrorData
99
- }
100
-
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 legacy JWTService with its `noTimestamp: true` default).
122
- */
123
- issuedAt?: UnixTimestamp
124
- /**
125
- * Sets the `kid` (key id) header, required by some APIs (e.g Apple App Store Connect).
126
- */
127
- kid?: string
128
- /**
129
- * Overrides cfg.schema for this call.
130
- */
131
- schema?: JSchema<T, any> | AjvSchema<T>
132
- }
133
-
134
- export interface JWTVerifyOptions<T extends AnyObject = AnyObject> {
135
- audience?: string | string[]
136
- issuer?: string | string[]
137
- subject?: string
138
- /**
139
- * Clock skew tolerance, in seconds.
140
- */
141
- clockTolerance?: NumberOfSeconds
142
- /**
143
- * Maximum allowed age of the token (based on its `iat` claim), in seconds.
144
- */
145
- maxTokenAge?: NumberOfSeconds
146
- /**
147
- * "Now" override, useful in tests.
148
- */
149
- now?: UnixTimestamp
150
- requiredClaims?: string[]
151
- /**
152
- * Overrides cfg.schema for this call.
153
- */
154
- schema?: JSchema<T, any> | AjvSchema<T>
155
- /**
156
- * Overrides cfg.publicKey for this call,
157
- * e.g when verifying tokens signed with different keys (kid-based).
158
- */
159
- publicKey?: string | Buffer
160
- }
161
-
162
- export interface JWTDecodeOptions<T extends AnyObject = AnyObject> {
163
- /**
164
- * Overrides cfg.schema for this call.
165
- */
166
- schema?: JSchema<T, any> | AjvSchema<T>
167
- }
168
-
169
- export interface JWTHeader {
170
- alg: string
171
- typ?: string
172
- kid?: string
173
- [k: string]: unknown
174
- }
175
-
176
- export interface JWTDecoded<T extends AnyObject> {
177
- header: JWTHeader
178
- payload: T
179
- signature: string
180
- }
181
-
182
- /**
183
- * Wraps the `jose` library, exposing an implementation-agnostic API:
184
- * no jose types, options or errors leak out of this service.
185
- * All errors are normalized into JWTError subclasses
186
- * (JWTExpiredError / JWTNotYetValidError / JWTInvalidError), matchable with `instanceof`.
187
- *
188
- * Successor of JWTService (jsonwebtoken-based). Tokens are wire-compatible
189
- * in both directions, so the two services can be swapped freely for the same key pair.
190
- * Difference is that Sign and Verify are async (jose is WebCrypto-based, which is Promise-only),
191
- * while Decode remains sync (pure base64url/JSON parsing, no crypto).
192
- *
193
- * You should create one instance of JWTService2 for each pair of private/public key.
194
- * Providing cfg.schema types the service to its payload and validates it
195
- * on every Sign/Verify/Decode; it can be overridden per-call via opt.schema.
196
- *
197
- * Generate key pair like this.
198
- * Please note that parameters should be different for different algorithms.
199
- * For ES256 (default algo in JWTService2) key should have `prime256v1` parameter:
200
- *
201
- * openssl ecparam -name prime256v1 -genkey -noout -out key.pem
202
- * openssl ec -in key.pem -pubout > key.pub.pem
203
- */
204
- export class JWTService2<T extends AnyObject = AnyObject> {
205
- private privateKey?: KeyObject
206
- private publicKey?: KeyObject
207
-
208
- constructor(public cfg: JWTService2Cfg<T>) {
209
- // KeyObjects are parsed synchronously via node:crypto (also accepts SEC1 PEMs,
210
- // which jose's own async importers reject), keeping the constructor sync.
211
- if (cfg.privateKey) this.privateKey = createPrivateKey(cfg.privateKey)
212
- if (cfg.publicKey) this.publicKey = createPublicKey(cfg.publicKey)
213
- }
214
-
215
- async sign<TT extends T = T>(payload: TT, opt: JWTSignOptions<TT>): Promise<JWTString> {
216
- _assert(
217
- this.privateKey,
218
- 'JWTService2: privateKey is required to be able to sign, but not provided',
219
- this.cfg.errorData,
220
- )
221
-
222
- const { expiresAt, notBefore, issuer, audience, subject, jwtid, issuedAt, kid, schema } = {
223
- ...this.cfg.signOptions,
224
- ...opt,
225
- }
226
-
227
- ;(schema || this.cfg.schema)?.validate(payload)
228
-
229
- // `kid: undefined` is dropped by JSON serialization, keeping the header unchanged when not set
230
- const jwt = new SignJWT(payload).setProtectedHeader({
231
- alg: this.cfg.algorithm,
232
- typ: 'JWT',
233
- kid,
234
- })
235
- if (expiresAt !== null) jwt.setExpirationTime(expiresAt)
236
- if (notBefore !== undefined) jwt.setNotBefore(notBefore)
237
- if (issuer) jwt.setIssuer(issuer)
238
- if (audience) jwt.setAudience(audience)
239
- if (subject) jwt.setSubject(subject)
240
- if (jwtid) jwt.setJti(jwtid)
241
- if (issuedAt !== undefined) jwt.setIssuedAt(issuedAt)
242
-
243
- return await jwt.sign(this.privateKey)
244
- }
245
-
246
- async verify<TT extends T = T>(token: JWTString, opt: JWTVerifyOptions<TT> = {}): Promise<TT> {
247
- const { now, schema, publicKey, ...joseOpt } = {
248
- ...this.cfg.verifyOptions,
249
- ...opt,
250
- }
251
-
252
- const key = publicKey ? createPublicKey(publicKey) : this.publicKey
253
- _assert(
254
- key,
255
- 'JWTService2: publicKey is required to be able to verify, but not provided',
256
- this.cfg.errorData,
257
- )
258
-
259
- let data: TT
260
-
261
- try {
262
- const { payload } = await jwtVerify(token, key, {
263
- algorithms: this.cfg.verifyAlgorithms || [this.cfg.algorithm],
264
- ...joseOpt,
265
- currentDate: now === undefined ? undefined : new Date(now * 1000),
266
- })
267
- data = payload as TT
268
- } catch (err) {
269
- throw this.normalizeError(err)
270
- }
271
-
272
- this.validate(data, schema)
273
-
274
- return data
275
- }
276
-
277
- /**
278
- * Tries to Verify the token, returning an [error, payload] tuple instead of throwing:
279
- * - [null, payload] - the token verified, payload can be trusted
280
- * - [error, null] - verification failed
281
- *
282
- * Same contract as tryToVerifyOrDecode, but without the unverified-decode fallback:
283
- * the payload is only returned when the token verified.
284
- */
285
- async tryToVerify<TT extends T = T>(
286
- token: JWTString,
287
- opt: JWTVerifyOptions<TT> = {},
288
- ): Promise<[err: null, payload: TT] | [err: Error, payload: null]> {
289
- try {
290
- return [null, await this.verify<TT>(token, opt)]
291
- } catch (err) {
292
- return [err as Error, null]
293
- }
294
- }
295
-
296
- /**
297
- * Tries to Verify the token, falling back to unverified Decode on failure,
298
- * so the caller can "peek" into the token's content even when it doesn't verify.
299
- *
300
- * Returns an [error, payload] tuple - ALWAYS check the error first:
301
- * - [null, payload] - the token verified, payload can be trusted
302
- * - [error, payload] - verification failed, but the token could still be decoded.
303
- * When the error is JWTExpiredError/JWTNotYetValidError, the signature was already
304
- * verified and only the time-claim check failed, so the payload is trustworthy
305
- * (just expired/not yet valid). For any other error the payload is completely
306
- * UNVERIFIED - never trust it, only peek (e.g to log/report the claimed identity).
307
- * - [error, null] - the token could not even be decoded
308
- *
309
- * The decode fallback is raw: opt.schema/cfg.schema are only applied on the Verify path.
310
- */
311
- async tryToVerifyOrDecode<TT extends T = T>(
312
- token: JWTString,
313
- opt: JWTVerifyOptions<TT> = {},
314
- ): Promise<[err: null, payload: TT] | [err: Error, payload: TT | null]> {
315
- const [verifyError, payload] = await this.tryToVerify<TT>(token, opt)
316
- if (!verifyError) return [null, payload]
317
-
318
- // Expired/not-yet-valid errors already carry the (signature-verified) payload
319
- if (verifyError instanceof JWTExpiredError || verifyError instanceof JWTNotYetValidError) {
320
- return [verifyError, verifyError.payload as TT]
321
- }
322
-
323
- try {
324
- return [verifyError, jwtDecode<TT>(token).payload]
325
- } catch {
326
- return [verifyError, null]
327
- }
328
- }
329
-
330
- decode<TT extends T = T>(token: JWTString, opt: JWTDecodeOptions<TT> = {}): JWTDecoded<TT> {
331
- let decoded: JWTDecoded<TT>
332
-
333
- try {
334
- decoded = jwtDecode<TT>(token)
335
- } catch (err) {
336
- if (this.cfg.errorData) {
337
- _errorDataAppend(err, this.cfg.errorData)
338
- }
339
- throw err
340
- }
341
-
342
- this.validate(decoded.payload, opt.schema)
343
-
344
- return decoded
345
- }
346
-
347
- /**
348
- * Schema-validation errors on Verify/Decode are extended with cfg.errorData:
349
- * a token with a non-conforming payload is as unauthorized as an invalid one.
350
- * (On Sign the payload comes from own code, not from user input, so a validation
351
- * error there indicates a programming error and is thrown as-is.)
352
- */
353
- private validate<TT extends T>(payload: TT, schema?: JSchema<TT, any> | AjvSchema<TT>): void {
354
- try {
355
- ;(schema || this.cfg.schema)?.validate(payload)
356
- } catch (err) {
357
- if (this.cfg.errorData) {
358
- _errorDataAppend(err, this.cfg.errorData)
359
- }
360
- throw err
361
- }
362
- }
363
-
364
- /**
365
- * jose errors are normalized into JWTError subclasses (extended with cfg.errorData).
366
- * Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
367
- * indicate a programming error and are passed through as-is.
368
- */
369
- private normalizeError(err: unknown): Error {
370
- const { errorData } = this.cfg
371
-
372
- if (err instanceof errors.JWTExpired) {
373
- return new JWTExpiredError(err.message, errorData, { payload: err.payload })
374
- }
375
- if (err instanceof errors.JWTClaimValidationFailed && err.claim === 'nbf') {
376
- return new JWTNotYetValidError(err.message, errorData, { payload: err.payload })
377
- }
378
- if (err instanceof errors.JOSEError) {
379
- return new JWTInvalidError(err.message, errorData)
380
- }
381
- if (this.cfg.verifyAlgorithms && err instanceof TypeError) {
382
- // With multiple verifyAlgorithms, a token/key algorithm mismatch is reachable
383
- // by untrusted input, and jose reports it as TypeError - treat it as an invalid token
384
- return new JWTInvalidError(err.message, errorData)
385
- }
386
- return err as Error
387
- }
388
- }
389
-
390
- /**
391
- * Decodes a JWT without verifying its signature (no key needed) - never trust the result.
392
- * Standalone version of JWTService2.decode, for when there's no service instance at hand
393
- * (no schema validation, no errorData extension).
394
- *
395
- * Throws JWTInvalidError if the token cannot be decoded.
396
- */
397
- export function jwtDecode<T extends AnyObject>(token: JWTString): JWTDecoded<T> {
398
- let header: JWTHeader
399
- let payload: T
400
-
401
- try {
402
- header = decodeProtectedHeader(token) as JWTHeader
403
- payload = decodeJwt(token)
404
- } catch (err) {
405
- // The underlying message is folded in, as it carries the only specific detail
406
- throw new JWTInvalidError(`invalid token, unable to decode: ${(err as Error).message}`)
407
- }
408
-
409
- return {
410
- header,
411
- payload,
412
- signature: token.split('.')[2]!,
413
- }
414
- }
415
-
416
- export type JWTErrorCode = 'JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID'
417
-
418
- export interface JWTErrorData extends ErrorData {
419
- code: JWTErrorCode
420
- }
421
-
422
- /**
423
- * Thrown by JWTService2 on any Verify/Decode failure, always as one of its subclasses:
424
- * - JWTExpiredError - `exp` claim check failed
425
- * - JWTNotYetValidError - `nbf` claim check failed
426
- * - JWTInvalidError - anything else (malformed token, wrong signature, other claim mismatches)
427
- *
428
- * Match errors with `instanceof`, e.g `err instanceof JWTExpiredError`.
429
- * `data.code` carries the same distinction ('JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID'),
430
- * for when the error crosses a serialization boundary (e.g ErrorObject over HTTP)
431
- * where `instanceof` no longer works.
432
- *
433
- * The underlying jose error is deliberately NOT preserved in `cause`: everything useful
434
- * from it is already carried (message is copied, the failed claim is the subclass,
435
- * `payload` is exposed where trustworthy), and jose nests the raw JWT payload into its own
436
- * `cause`, which would otherwise survive ErrorObject serialization and leak into logs.
437
- */
438
- export class JWTError extends AppError<JWTErrorData> {
439
- constructor(message: string, data: JWTErrorData) {
440
- // `new.target.name` makes subclasses report their own name
441
- super(message, data, { name: new.target.name })
442
- }
443
- }
444
-
445
- /**
446
- * The token is well-formed and its signature is valid, but the `exp` claim check failed.
447
- *
448
- * `payload` carries the decoded token payload: signature is verified before claims are
449
- * checked, so the payload is trustworthy - it's just expired.
450
- * (Note: it's the raw JWT payload, incl. standard claims; cfg.schema is not applied to it.)
451
- */
452
- export class JWTExpiredError extends JWTError {
453
- readonly payload: AnyObject
454
-
455
- constructor(message: string, data: ErrorData = {}, opt: { payload?: AnyObject } = {}) {
456
- super(message, { ...data, code: 'JWT_EXPIRED' })
457
- this.payload = opt.payload || {}
458
- }
459
- }
460
-
461
- /**
462
- * The token is well-formed and its signature is valid, but the `nbf` claim check failed.
463
- *
464
- * `payload` carries the decoded token payload: signature is verified before claims are
465
- * checked, so the payload is trustworthy - it's just not valid yet.
466
- * (Note: it's the raw JWT payload, incl. standard claims; cfg.schema is not applied to it.)
467
- */
468
- export class JWTNotYetValidError extends JWTError {
469
- readonly payload: AnyObject
470
-
471
- constructor(message: string, data: ErrorData = {}, opt: { payload?: AnyObject } = {}) {
472
- super(message, { ...data, code: 'JWT_NOT_YET_VALID' })
473
- this.payload = opt.payload || {}
474
- }
475
- }
476
-
477
- /**
478
- * The token could not be trusted: malformed token, wrong signature,
479
- * or a claim mismatch other than exp/nbf (e.g issuer/audience).
480
- * No payload is exposed - nothing from such a token should be used.
481
- */
482
- export class JWTInvalidError extends JWTError {
483
- constructor(message: string, data: ErrorData = {}) {
484
- super(message, { ...data, code: 'JWT_INVALID' })
485
- }
486
- }