@naturalcycles/nodejs-lib 15.115.1 → 15.116.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.
@@ -0,0 +1,2 @@
1
+ export * from './jwt.service.js';
2
+ export * from './jwt.service2.js';
@@ -0,0 +1,2 @@
1
+ export * from './jwt.service.js';
2
+ export * from './jwt.service2.js';
@@ -37,6 +37,11 @@ export interface JWTServiceCfg {
37
37
  errorData?: ErrorData;
38
38
  }
39
39
  /**
40
+ * @deprecated use JWTService2 (jose-based) instead.
41
+ * Tokens are wire-compatible in both directions, only the API differs
42
+ * (async sign/verify, normalized JWTError).
43
+ * JWTService2 will be renamed to JWTService when this class is dropped.
44
+ *
40
45
  * Wraps popular `jsonwebtoken` library.
41
46
  * You should create one instance of JWTService for each pair of private/public key.
42
47
  *
@@ -7,6 +7,11 @@ export { jsonwebtoken };
7
7
  // jwt invalid
8
8
  // jwt token is empty
9
9
  /**
10
+ * @deprecated use JWTService2 (jose-based) instead.
11
+ * Tokens are wire-compatible in both directions, only the API differs
12
+ * (async sign/verify, normalized JWTError).
13
+ * JWTService2 will be renamed to JWTService when this class is dropped.
14
+ *
10
15
  * Wraps popular `jsonwebtoken` library.
11
16
  * You should create one instance of JWTService for each pair of private/public key.
12
17
  *
@@ -0,0 +1,184 @@
1
+ import type { ErrorData } from '@naturalcycles/js-lib/error';
2
+ import { AppError } from '@naturalcycles/js-lib/error/error.util.js';
3
+ import type { AnyObject, JWTString, NumberOfSeconds, UnixTimestamp } from '@naturalcycles/js-lib/types';
4
+ import type { AjvSchema, JSchema } from '../validation/ajv/jSchema.js';
5
+ /**
6
+ * Asymmetric JWS algorithms supported by JWTService2.
7
+ */
8
+ export type JWTAlgorithm = 'ES256' | 'ES384' | 'ES512' | 'RS256' | 'RS384' | 'RS512' | 'PS256' | 'PS384' | 'PS512' | 'EdDSA';
9
+ export interface JWTService2Cfg<T extends AnyObject = AnyObject> {
10
+ /**
11
+ * Public key is required to Verify incoming tokens.
12
+ * Optional if you only want to Decode or Sign.
13
+ *
14
+ * PEM string/Buffer. Both SPKI ("PUBLIC KEY") and a private key PEM
15
+ * (public key is derived from it) are accepted.
16
+ */
17
+ publicKey?: string | Buffer;
18
+ /**
19
+ * Private key is required to Sign (create) outgoing tokens.
20
+ * Optional if you only want to Decode or Verify.
21
+ *
22
+ * PEM string/Buffer. Both PKCS8 ("PRIVATE KEY") and SEC1 ("EC PRIVATE KEY",
23
+ * as generated by `openssl ecparam`) are accepted.
24
+ */
25
+ privateKey?: string | Buffer;
26
+ /**
27
+ * Recommended: ES256
28
+ * Keys (private/public) should be generated using proper settings
29
+ * that fit the used Algorithm.
30
+ */
31
+ algorithm: JWTAlgorithm;
32
+ /**
33
+ * If provided - payloads are validated against it on every Sign/Verify/Decode.
34
+ * Can be overridden per-call via `opt.schema`.
35
+ *
36
+ * Note: on Verify/Decode the schema is applied to the raw JWT payload, which includes
37
+ * the standard claims (exp/nbf/iss/aud/sub/iat/jti) if they were set on Sign.
38
+ * Declare (or allow) them in the schema, otherwise a strict schema will
39
+ * strip them from the returned payload (default AjvSchema behavior: removeAdditional).
40
+ */
41
+ schema?: JSchema<T, any> | AjvSchema<T>;
42
+ /**
43
+ * If provided - will be applied to every Sign operation.
44
+ * Absolute-timestamp options (expiresAt/notBefore/issuedAt) are excluded,
45
+ * as they would be fixed to the same moment for all tokens signed by this service.
46
+ */
47
+ signOptions?: Omit<JWTSignOptions, 'expiresAt' | 'notBefore' | 'issuedAt' | 'schema'>;
48
+ /**
49
+ * If provided - will be applied to every Verify operation.
50
+ */
51
+ verifyOptions?: Omit<JWTVerifyOptions, 'schema' | 'publicKey'>;
52
+ /**
53
+ * If set - JWTErrors thrown from this service will be extended
54
+ * with this errorData (in err.data)
55
+ */
56
+ errorData?: ErrorData;
57
+ }
58
+ export interface JWTSignOptions<T extends AnyObject = AnyObject> {
59
+ /**
60
+ * Sets the `exp` claim, as an absolute UnixTimestamp.
61
+ * Convenient to build with LocalTime, e.g:
62
+ * `localTime.now().plus(30, 'minute').unix`
63
+ *
64
+ * Required, to protect from accidentally issuing never-expiring tokens.
65
+ * Pass `null` to explicitly sign a token without expiration.
66
+ */
67
+ expiresAt: UnixTimestamp | null;
68
+ /**
69
+ * Sets the `nbf` (not before) claim, as an absolute UnixTimestamp.
70
+ */
71
+ notBefore?: UnixTimestamp;
72
+ issuer?: string;
73
+ audience?: string | string[];
74
+ subject?: string;
75
+ jwtid?: string;
76
+ /**
77
+ * Sets the `iat` claim, as an absolute UnixTimestamp (e.g `localTime.nowUnix()`).
78
+ * By default `iat` is NOT set (same as legacy JWTService with its `noTimestamp: true` default).
79
+ */
80
+ issuedAt?: UnixTimestamp;
81
+ /**
82
+ * Overrides cfg.schema for this call.
83
+ */
84
+ schema?: JSchema<T, any> | AjvSchema<T>;
85
+ }
86
+ export interface JWTVerifyOptions<T extends AnyObject = AnyObject> {
87
+ audience?: string | string[];
88
+ issuer?: string | string[];
89
+ subject?: string;
90
+ /**
91
+ * Clock skew tolerance, in seconds.
92
+ */
93
+ clockTolerance?: NumberOfSeconds;
94
+ /**
95
+ * Maximum allowed age of the token (based on its `iat` claim), in seconds.
96
+ */
97
+ maxTokenAge?: NumberOfSeconds;
98
+ /**
99
+ * "Now" override, useful in tests.
100
+ */
101
+ now?: UnixTimestamp;
102
+ requiredClaims?: string[];
103
+ /**
104
+ * Overrides cfg.schema for this call.
105
+ */
106
+ schema?: JSchema<T, any> | AjvSchema<T>;
107
+ /**
108
+ * Overrides cfg.publicKey for this call,
109
+ * e.g when verifying tokens signed with different keys (kid-based).
110
+ */
111
+ publicKey?: string | Buffer;
112
+ }
113
+ export interface JWTDecodeOptions<T extends AnyObject = AnyObject> {
114
+ /**
115
+ * Overrides cfg.schema for this call.
116
+ */
117
+ schema?: JSchema<T, any> | AjvSchema<T>;
118
+ }
119
+ export interface JWTHeader {
120
+ alg: string;
121
+ typ?: string;
122
+ kid?: string;
123
+ [k: string]: unknown;
124
+ }
125
+ export interface JWTDecoded<T extends AnyObject> {
126
+ header: JWTHeader;
127
+ payload: T;
128
+ signature: string;
129
+ }
130
+ /**
131
+ * Wraps the `jose` library, exposing an implementation-agnostic API:
132
+ * no jose types, options or errors leak out of this service.
133
+ * All errors are normalized into JWTError with a stable `data.code`.
134
+ *
135
+ * Successor of JWTService (jsonwebtoken-based). Tokens are wire-compatible
136
+ * in both directions, so the two services can be swapped freely for the same key pair.
137
+ * Difference is that Sign and Verify are async (jose is WebCrypto-based, which is Promise-only),
138
+ * while Decode remains sync (pure base64url/JSON parsing, no crypto).
139
+ *
140
+ * You should create one instance of JWTService2 for each pair of private/public key.
141
+ * Providing cfg.schema types the service to its payload and validates it
142
+ * on every Sign/Verify/Decode; it can be overridden per-call via opt.schema.
143
+ *
144
+ * Generate key pair like this.
145
+ * Please note that parameters should be different for different algorithms.
146
+ * For ES256 (default algo in JWTService2) key should have `prime256v1` parameter:
147
+ *
148
+ * openssl ecparam -name prime256v1 -genkey -noout -out key.pem
149
+ * openssl ec -in key.pem -pubout > key.pub.pem
150
+ */
151
+ export declare class JWTService2<T extends AnyObject = AnyObject> {
152
+ cfg: JWTService2Cfg<T>;
153
+ private privateKey?;
154
+ private publicKey?;
155
+ constructor(cfg: JWTService2Cfg<T>);
156
+ sign<TT extends T = T>(payload: TT, opt: JWTSignOptions<TT>): Promise<JWTString>;
157
+ verify<TT extends T = T>(token: JWTString, opt?: JWTVerifyOptions<TT>): Promise<TT>;
158
+ decode<TT extends T = T>(token: JWTString, opt?: JWTDecodeOptions<TT>): JWTDecoded<TT>;
159
+ /**
160
+ * jose errors are normalized into JWTError (extended with cfg.errorData).
161
+ * Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
162
+ * indicate a programming error and are passed through as-is.
163
+ */
164
+ private normalizeError;
165
+ }
166
+ export type JWTErrorCode = 'JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID';
167
+ export interface JWTErrorData extends ErrorData {
168
+ code: JWTErrorCode;
169
+ }
170
+ /**
171
+ * Thrown by JWTService2 on any Verify/Decode failure.
172
+ *
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
+ *
178
+ * The original underlying error is preserved in `cause`.
179
+ */
180
+ export declare class JWTError extends AppError<JWTErrorData> {
181
+ constructor(message: string, data: JWTErrorData, opt?: {
182
+ cause?: any;
183
+ });
184
+ }
@@ -0,0 +1,146 @@
1
+ import { createPrivateKey, createPublicKey } from 'node:crypto';
2
+ import { _assert } from '@naturalcycles/js-lib/error/assert.js';
3
+ import { AppError } from '@naturalcycles/js-lib/error/error.util.js';
4
+ import { decodeJwt, decodeProtectedHeader, errors, jwtVerify, SignJWT } from 'jose';
5
+ /**
6
+ * Wraps the `jose` library, exposing an implementation-agnostic API:
7
+ * no jose types, options or errors leak out of this service.
8
+ * All errors are normalized into JWTError with a stable `data.code`.
9
+ *
10
+ * Successor of JWTService (jsonwebtoken-based). Tokens are wire-compatible
11
+ * in both directions, so the two services can be swapped freely for the same key pair.
12
+ * Difference is that Sign and Verify are async (jose is WebCrypto-based, which is Promise-only),
13
+ * while Decode remains sync (pure base64url/JSON parsing, no crypto).
14
+ *
15
+ * You should create one instance of JWTService2 for each pair of private/public key.
16
+ * Providing cfg.schema types the service to its payload and validates it
17
+ * on every Sign/Verify/Decode; it can be overridden per-call via opt.schema.
18
+ *
19
+ * Generate key pair like this.
20
+ * Please note that parameters should be different for different algorithms.
21
+ * For ES256 (default algo in JWTService2) key should have `prime256v1` parameter:
22
+ *
23
+ * openssl ecparam -name prime256v1 -genkey -noout -out key.pem
24
+ * openssl ec -in key.pem -pubout > key.pub.pem
25
+ */
26
+ export class JWTService2 {
27
+ cfg;
28
+ privateKey;
29
+ publicKey;
30
+ constructor(cfg) {
31
+ this.cfg = cfg;
32
+ // KeyObjects are parsed synchronously via node:crypto (also accepts SEC1 PEMs,
33
+ // which jose's own async importers reject), keeping the constructor sync.
34
+ if (cfg.privateKey)
35
+ this.privateKey = createPrivateKey(cfg.privateKey);
36
+ if (cfg.publicKey)
37
+ this.publicKey = createPublicKey(cfg.publicKey);
38
+ }
39
+ async sign(payload, opt) {
40
+ _assert(this.privateKey, 'JWTService2: privateKey is required to be able to sign, but not provided', this.cfg.errorData);
41
+ const { expiresAt, notBefore, issuer, audience, subject, jwtid, issuedAt, schema } = {
42
+ ...this.cfg.signOptions,
43
+ ...opt,
44
+ };
45
+ (schema || this.cfg.schema)?.validate(payload);
46
+ const jwt = new SignJWT(payload).setProtectedHeader({ alg: this.cfg.algorithm, typ: 'JWT' });
47
+ if (expiresAt !== null)
48
+ jwt.setExpirationTime(expiresAt);
49
+ if (notBefore !== undefined)
50
+ jwt.setNotBefore(notBefore);
51
+ if (issuer)
52
+ jwt.setIssuer(issuer);
53
+ if (audience)
54
+ jwt.setAudience(audience);
55
+ if (subject)
56
+ jwt.setSubject(subject);
57
+ if (jwtid)
58
+ jwt.setJti(jwtid);
59
+ if (issuedAt !== undefined)
60
+ jwt.setIssuedAt(issuedAt);
61
+ return await jwt.sign(this.privateKey);
62
+ }
63
+ async verify(token, opt = {}) {
64
+ const { now, schema, publicKey, ...joseOpt } = {
65
+ ...this.cfg.verifyOptions,
66
+ ...opt,
67
+ };
68
+ const key = publicKey ? createPublicKey(publicKey) : this.publicKey;
69
+ _assert(key, 'JWTService2: publicKey is required to be able to verify, but not provided', this.cfg.errorData);
70
+ let data;
71
+ try {
72
+ const { payload } = await jwtVerify(token, key, {
73
+ algorithms: [this.cfg.algorithm],
74
+ ...joseOpt,
75
+ currentDate: now === undefined ? undefined : new Date(now * 1000),
76
+ });
77
+ data = payload;
78
+ }
79
+ catch (err) {
80
+ throw this.normalizeError(err);
81
+ }
82
+ ;
83
+ (schema || this.cfg.schema)?.validate(data);
84
+ return data;
85
+ }
86
+ decode(token, opt = {}) {
87
+ let header;
88
+ let payload;
89
+ try {
90
+ header = decodeProtectedHeader(token);
91
+ payload = decodeJwt(token);
92
+ }
93
+ catch (err) {
94
+ throw new JWTError('invalid token, unable to decode', {
95
+ ...this.cfg.errorData,
96
+ code: 'JWT_INVALID',
97
+ }, { cause: err });
98
+ }
99
+ ;
100
+ (opt.schema || this.cfg.schema)?.validate(payload);
101
+ return {
102
+ header,
103
+ payload,
104
+ signature: token.split('.')[2],
105
+ };
106
+ }
107
+ /**
108
+ * jose errors are normalized into JWTError (extended with cfg.errorData).
109
+ * Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
110
+ * indicate a programming error and are passed through as-is.
111
+ */
112
+ normalizeError(err) {
113
+ let code;
114
+ if (err instanceof errors.JWTExpired) {
115
+ code = 'JWT_EXPIRED';
116
+ }
117
+ else if (err instanceof errors.JWTClaimValidationFailed && err.claim === 'nbf') {
118
+ code = 'JWT_NOT_YET_VALID';
119
+ }
120
+ else if (err instanceof errors.JOSEError) {
121
+ code = 'JWT_INVALID';
122
+ }
123
+ else {
124
+ return err;
125
+ }
126
+ return new JWTError(err.message, {
127
+ ...this.cfg.errorData,
128
+ code,
129
+ }, { cause: err });
130
+ }
131
+ }
132
+ /**
133
+ * Thrown by JWTService2 on any Verify/Decode failure.
134
+ *
135
+ * `data.code` is stable and implementation-agnostic:
136
+ * - JWT_EXPIRED - `exp` claim check failed
137
+ * - JWT_NOT_YET_VALID - `nbf` claim check failed
138
+ * - JWT_INVALID - anything else (malformed token, wrong signature, other claim mismatches)
139
+ *
140
+ * The original underlying error is preserved in `cause`.
141
+ */
142
+ export class JWTError extends AppError {
143
+ constructor(message, data, opt) {
144
+ super(message, data, { ...opt, name: 'JWTError' });
145
+ }
146
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@naturalcycles/nodejs-lib",
3
3
  "type": "module",
4
- "version": "15.115.1",
4
+ "version": "15.116.0",
5
5
  "dependencies": {
6
6
  "@naturalcycles/js-lib": "^15",
7
7
  "@standard-schema/spec": "^1",
8
8
  "@types/jsonwebtoken": "^9",
9
9
  "ajv": "^8",
10
10
  "ansis": "^4",
11
+ "jose": "^6",
11
12
  "jsonwebtoken": "^9",
12
13
  "lru-cache": "^11",
13
14
  "tinyglobby": "^0.2",
@@ -30,7 +31,7 @@
30
31
  "./kpy": "./dist/fs/kpy.js",
31
32
  "./yaml2": "./dist/fs/yaml2.js",
32
33
  "./glob": "./dist/glob/index.js",
33
- "./jwt": "./dist/jwt/jwt.service.js",
34
+ "./jwt": "./dist/jwt/index.js",
34
35
  "./runScript": "./dist/script/runScript.js",
35
36
  "./slack": "./dist/slack/index.js",
36
37
  "./stream": "./dist/stream/index.js",
@@ -0,0 +1,2 @@
1
+ export * from './jwt.service.js'
2
+ export * from './jwt.service2.js'
@@ -51,6 +51,11 @@ export interface JWTServiceCfg {
51
51
  // jwt token is empty
52
52
 
53
53
  /**
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.
58
+ *
54
59
  * Wraps popular `jsonwebtoken` library.
55
60
  * You should create one instance of JWTService for each pair of private/public key.
56
61
  *
@@ -0,0 +1,327 @@
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 { 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
+ algorithm: JWTAlgorithm
54
+
55
+ /**
56
+ * If provided - payloads are validated against it on every Sign/Verify/Decode.
57
+ * Can be overridden per-call via `opt.schema`.
58
+ *
59
+ * Note: on Verify/Decode the schema is applied to the raw JWT payload, which includes
60
+ * the standard claims (exp/nbf/iss/aud/sub/iat/jti) if they were set on Sign.
61
+ * Declare (or allow) them in the schema, otherwise a strict schema will
62
+ * strip them from the returned payload (default AjvSchema behavior: removeAdditional).
63
+ */
64
+ schema?: JSchema<T, any> | AjvSchema<T>
65
+
66
+ /**
67
+ * If provided - will be applied to every Sign operation.
68
+ * Absolute-timestamp options (expiresAt/notBefore/issuedAt) are excluded,
69
+ * as they would be fixed to the same moment for all tokens signed by this service.
70
+ */
71
+ signOptions?: Omit<JWTSignOptions, 'expiresAt' | 'notBefore' | 'issuedAt' | 'schema'>
72
+
73
+ /**
74
+ * If provided - will be applied to every Verify operation.
75
+ */
76
+ verifyOptions?: Omit<JWTVerifyOptions, 'schema' | 'publicKey'>
77
+
78
+ /**
79
+ * If set - JWTErrors thrown from this service will be extended
80
+ * with this errorData (in err.data)
81
+ */
82
+ errorData?: ErrorData
83
+ }
84
+
85
+ export interface JWTSignOptions<T extends AnyObject = AnyObject> {
86
+ /**
87
+ * Sets the `exp` claim, as an absolute UnixTimestamp.
88
+ * Convenient to build with LocalTime, e.g:
89
+ * `localTime.now().plus(30, 'minute').unix`
90
+ *
91
+ * Required, to protect from accidentally issuing never-expiring tokens.
92
+ * Pass `null` to explicitly sign a token without expiration.
93
+ */
94
+ expiresAt: UnixTimestamp | null
95
+ /**
96
+ * Sets the `nbf` (not before) claim, as an absolute UnixTimestamp.
97
+ */
98
+ notBefore?: UnixTimestamp
99
+ issuer?: string
100
+ audience?: string | string[]
101
+ subject?: string
102
+ jwtid?: string
103
+ /**
104
+ * Sets the `iat` claim, as an absolute UnixTimestamp (e.g `localTime.nowUnix()`).
105
+ * By default `iat` is NOT set (same as legacy JWTService with its `noTimestamp: true` default).
106
+ */
107
+ issuedAt?: UnixTimestamp
108
+ /**
109
+ * Overrides cfg.schema for this call.
110
+ */
111
+ schema?: JSchema<T, any> | AjvSchema<T>
112
+ }
113
+
114
+ export interface JWTVerifyOptions<T extends AnyObject = AnyObject> {
115
+ audience?: string | string[]
116
+ issuer?: string | string[]
117
+ subject?: string
118
+ /**
119
+ * Clock skew tolerance, in seconds.
120
+ */
121
+ clockTolerance?: NumberOfSeconds
122
+ /**
123
+ * Maximum allowed age of the token (based on its `iat` claim), in seconds.
124
+ */
125
+ maxTokenAge?: NumberOfSeconds
126
+ /**
127
+ * "Now" override, useful in tests.
128
+ */
129
+ now?: UnixTimestamp
130
+ requiredClaims?: string[]
131
+ /**
132
+ * Overrides cfg.schema for this call.
133
+ */
134
+ schema?: JSchema<T, any> | AjvSchema<T>
135
+ /**
136
+ * Overrides cfg.publicKey for this call,
137
+ * e.g when verifying tokens signed with different keys (kid-based).
138
+ */
139
+ publicKey?: string | Buffer
140
+ }
141
+
142
+ export interface JWTDecodeOptions<T extends AnyObject = AnyObject> {
143
+ /**
144
+ * Overrides cfg.schema for this call.
145
+ */
146
+ schema?: JSchema<T, any> | AjvSchema<T>
147
+ }
148
+
149
+ export interface JWTHeader {
150
+ alg: string
151
+ typ?: string
152
+ kid?: string
153
+ [k: string]: unknown
154
+ }
155
+
156
+ export interface JWTDecoded<T extends AnyObject> {
157
+ header: JWTHeader
158
+ payload: T
159
+ signature: string
160
+ }
161
+
162
+ /**
163
+ * Wraps the `jose` library, exposing an implementation-agnostic API:
164
+ * no jose types, options or errors leak out of this service.
165
+ * All errors are normalized into JWTError with a stable `data.code`.
166
+ *
167
+ * Successor of JWTService (jsonwebtoken-based). Tokens are wire-compatible
168
+ * in both directions, so the two services can be swapped freely for the same key pair.
169
+ * Difference is that Sign and Verify are async (jose is WebCrypto-based, which is Promise-only),
170
+ * while Decode remains sync (pure base64url/JSON parsing, no crypto).
171
+ *
172
+ * You should create one instance of JWTService2 for each pair of private/public key.
173
+ * Providing cfg.schema types the service to its payload and validates it
174
+ * on every Sign/Verify/Decode; it can be overridden per-call via opt.schema.
175
+ *
176
+ * Generate key pair like this.
177
+ * Please note that parameters should be different for different algorithms.
178
+ * For ES256 (default algo in JWTService2) key should have `prime256v1` parameter:
179
+ *
180
+ * openssl ecparam -name prime256v1 -genkey -noout -out key.pem
181
+ * openssl ec -in key.pem -pubout > key.pub.pem
182
+ */
183
+ export class JWTService2<T extends AnyObject = AnyObject> {
184
+ private privateKey?: KeyObject
185
+ private publicKey?: KeyObject
186
+
187
+ constructor(public cfg: JWTService2Cfg<T>) {
188
+ // KeyObjects are parsed synchronously via node:crypto (also accepts SEC1 PEMs,
189
+ // which jose's own async importers reject), keeping the constructor sync.
190
+ if (cfg.privateKey) this.privateKey = createPrivateKey(cfg.privateKey)
191
+ if (cfg.publicKey) this.publicKey = createPublicKey(cfg.publicKey)
192
+ }
193
+
194
+ async sign<TT extends T = T>(payload: TT, opt: JWTSignOptions<TT>): Promise<JWTString> {
195
+ _assert(
196
+ this.privateKey,
197
+ 'JWTService2: privateKey is required to be able to sign, but not provided',
198
+ this.cfg.errorData,
199
+ )
200
+
201
+ const { expiresAt, notBefore, issuer, audience, subject, jwtid, issuedAt, schema } = {
202
+ ...this.cfg.signOptions,
203
+ ...opt,
204
+ }
205
+
206
+ ;(schema || this.cfg.schema)?.validate(payload)
207
+
208
+ const jwt = new SignJWT(payload).setProtectedHeader({ alg: this.cfg.algorithm, typ: 'JWT' })
209
+ if (expiresAt !== null) jwt.setExpirationTime(expiresAt)
210
+ if (notBefore !== undefined) jwt.setNotBefore(notBefore)
211
+ if (issuer) jwt.setIssuer(issuer)
212
+ if (audience) jwt.setAudience(audience)
213
+ if (subject) jwt.setSubject(subject)
214
+ if (jwtid) jwt.setJti(jwtid)
215
+ if (issuedAt !== undefined) jwt.setIssuedAt(issuedAt)
216
+
217
+ return await jwt.sign(this.privateKey)
218
+ }
219
+
220
+ async verify<TT extends T = T>(token: JWTString, opt: JWTVerifyOptions<TT> = {}): Promise<TT> {
221
+ const { now, schema, publicKey, ...joseOpt } = {
222
+ ...this.cfg.verifyOptions,
223
+ ...opt,
224
+ }
225
+
226
+ const key = publicKey ? createPublicKey(publicKey) : this.publicKey
227
+ _assert(
228
+ key,
229
+ 'JWTService2: publicKey is required to be able to verify, but not provided',
230
+ this.cfg.errorData,
231
+ )
232
+
233
+ let data: TT
234
+
235
+ try {
236
+ const { payload } = await jwtVerify(token, key, {
237
+ algorithms: [this.cfg.algorithm],
238
+ ...joseOpt,
239
+ currentDate: now === undefined ? undefined : new Date(now * 1000),
240
+ })
241
+ data = payload as TT
242
+ } catch (err) {
243
+ throw this.normalizeError(err)
244
+ }
245
+
246
+ ;(schema || this.cfg.schema)?.validate(data)
247
+
248
+ return data
249
+ }
250
+
251
+ decode<TT extends T = T>(token: JWTString, opt: JWTDecodeOptions<TT> = {}): JWTDecoded<TT> {
252
+ let header: JWTHeader
253
+ let payload: TT
254
+
255
+ try {
256
+ header = decodeProtectedHeader(token) as JWTHeader
257
+ payload = decodeJwt(token) as TT
258
+ } catch (err) {
259
+ throw new JWTError(
260
+ 'invalid token, unable to decode',
261
+ {
262
+ ...this.cfg.errorData,
263
+ code: 'JWT_INVALID',
264
+ },
265
+ { cause: err },
266
+ )
267
+ }
268
+
269
+ ;(opt.schema || this.cfg.schema)?.validate(payload)
270
+
271
+ return {
272
+ header,
273
+ payload,
274
+ signature: token.split('.')[2]!,
275
+ }
276
+ }
277
+
278
+ /**
279
+ * jose errors are normalized into JWTError (extended with cfg.errorData).
280
+ * Non-jose errors (e.g TypeError from passing a key that doesn't fit the algorithm)
281
+ * indicate a programming error and are passed through as-is.
282
+ */
283
+ private normalizeError(err: unknown): Error {
284
+ let code: JWTErrorCode
285
+
286
+ if (err instanceof errors.JWTExpired) {
287
+ code = 'JWT_EXPIRED'
288
+ } else if (err instanceof errors.JWTClaimValidationFailed && err.claim === 'nbf') {
289
+ code = 'JWT_NOT_YET_VALID'
290
+ } else if (err instanceof errors.JOSEError) {
291
+ code = 'JWT_INVALID'
292
+ } else {
293
+ return err as Error
294
+ }
295
+
296
+ return new JWTError(
297
+ (err as Error).message,
298
+ {
299
+ ...this.cfg.errorData,
300
+ code,
301
+ },
302
+ { cause: err },
303
+ )
304
+ }
305
+ }
306
+
307
+ export type JWTErrorCode = 'JWT_EXPIRED' | 'JWT_NOT_YET_VALID' | 'JWT_INVALID'
308
+
309
+ export interface JWTErrorData extends ErrorData {
310
+ code: JWTErrorCode
311
+ }
312
+
313
+ /**
314
+ * Thrown by JWTService2 on any Verify/Decode failure.
315
+ *
316
+ * `data.code` is stable and implementation-agnostic:
317
+ * - JWT_EXPIRED - `exp` claim check failed
318
+ * - JWT_NOT_YET_VALID - `nbf` claim check failed
319
+ * - JWT_INVALID - anything else (malformed token, wrong signature, other claim mismatches)
320
+ *
321
+ * The original underlying error is preserved in `cause`.
322
+ */
323
+ export class JWTError extends AppError<JWTErrorData> {
324
+ constructor(message: string, data: JWTErrorData, opt?: { cause?: any }) {
325
+ super(message, data, { ...opt, name: 'JWTError' })
326
+ }
327
+ }