@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.
- package/dist/jwt/index.d.ts +2 -0
- package/dist/jwt/index.js +2 -0
- package/dist/jwt/jwt.service.d.ts +5 -0
- package/dist/jwt/jwt.service.js +5 -0
- package/dist/jwt/jwt.service2.d.ts +184 -0
- package/dist/jwt/jwt.service2.js +146 -0
- package/package.json +3 -2
- package/src/jwt/index.ts +2 -0
- package/src/jwt/jwt.service.ts +5 -0
- package/src/jwt/jwt.service2.ts +327 -0
|
@@ -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
|
*
|
package/dist/jwt/jwt.service.js
CHANGED
|
@@ -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.
|
|
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/
|
|
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",
|
package/src/jwt/index.ts
ADDED
package/src/jwt/jwt.service.ts
CHANGED
|
@@ -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
|
+
}
|