@naturalcycles/nodejs-lib 15.115.0 → 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/cli/parseArgs.js +55 -0
- 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 +5 -4
- package/src/cli/parseArgs.ts +62 -0
- package/src/jwt/index.ts +2 -0
- package/src/jwt/jwt.service.ts +5 -0
- package/src/jwt/jwt.service2.ts +327 -0
package/dist/cli/parseArgs.js
CHANGED
|
@@ -43,6 +43,7 @@ export function _parseArgs(options, opt = {}) {
|
|
|
43
43
|
options: nodeOptions,
|
|
44
44
|
allowPositionals: true,
|
|
45
45
|
allowNegative: true, // native `--no-flag` support for booleans
|
|
46
|
+
tokens: true, // needed to detect the ambiguous `--boolFlag value` space form
|
|
46
47
|
// In non-strict mode, unknown options are collected into `values` but
|
|
47
48
|
// ignored below, since we only read declared options. See `strict` docs.
|
|
48
49
|
strict,
|
|
@@ -57,6 +58,7 @@ export function _parseArgs(options, opt = {}) {
|
|
|
57
58
|
process.stdout.write(buildHelp(options, usage));
|
|
58
59
|
process.exit(0);
|
|
59
60
|
}
|
|
61
|
+
assertNoSpaceValuedBoolean(parsed.tokens, options);
|
|
60
62
|
const result = { _: parsed.positionals };
|
|
61
63
|
for (const [name, def] of Object.entries(options)) {
|
|
62
64
|
let v = values[name];
|
|
@@ -78,10 +80,32 @@ export function _parseArgs(options, opt = {}) {
|
|
|
78
80
|
if (def.array) {
|
|
79
81
|
v = Array.isArray(v) ? v : [v];
|
|
80
82
|
}
|
|
83
|
+
// A non-boolean option passed as a bare flag (`--out` with no value) comes back
|
|
84
|
+
// from node's parseArgs (in non-strict mode) as boolean `true`. Reject it: the
|
|
85
|
+
// user almost certainly forgot the value, and silently coercing `true` (to `1`
|
|
86
|
+
// for numbers, `"true"` for strings, or crashing a `transform`) would hide the
|
|
87
|
+
// mistake. Only arg-sourced values are checked; a boolean `default` is left
|
|
88
|
+
// alone. `def.type === 'boolean'` legitimately produces booleans, so skip it.
|
|
89
|
+
if (fromArgs && def.type !== 'boolean') {
|
|
90
|
+
const bareFlag = Array.isArray(v)
|
|
91
|
+
? v.some(x => typeof x === 'boolean')
|
|
92
|
+
: typeof v === 'boolean';
|
|
93
|
+
if (bareFlag) {
|
|
94
|
+
throw new ParseArgsError(`Missing value for --${name}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
81
97
|
// `transform` owns conversion, so built-in number coercion is skipped for it
|
|
82
98
|
if (def.type === 'number' && !def.transform) {
|
|
83
99
|
v = Array.isArray(v) ? v.map(x => toNumber(x, name)) : toNumber(v, name);
|
|
84
100
|
}
|
|
101
|
+
// node's parseArgs (in non-strict mode) captures the inline value of
|
|
102
|
+
// `--flag=value` on a boolean option as a string ("false"/"true"), rather than
|
|
103
|
+
// rejecting it as strict mode does. Coerce known tokens so `--flag=false` means
|
|
104
|
+
// boolean false, not a truthy "false" string. Real booleans produced by
|
|
105
|
+
// `--flag` / `--no-flag` (and boolean defaults) pass through untouched.
|
|
106
|
+
if (def.type === 'boolean') {
|
|
107
|
+
v = Array.isArray(v) ? v.map(x => toBoolean(x, name)) : toBoolean(v, name);
|
|
108
|
+
}
|
|
85
109
|
if (def.choices) {
|
|
86
110
|
const list = Array.isArray(v) ? v : [v];
|
|
87
111
|
for (const x of list) {
|
|
@@ -101,6 +125,28 @@ export function _parseArgs(options, opt = {}) {
|
|
|
101
125
|
}
|
|
102
126
|
return result;
|
|
103
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Reject the ambiguous `--boolFlag value` space form. node never consumes the
|
|
130
|
+
* next token as a boolean's value (getopt convention), so `--arg false` would
|
|
131
|
+
* silently yield `arg: true` and leak "false" into positionals. Unlike the
|
|
132
|
+
* `=value` form (handled by toBoolean) we can't recover the intended value here,
|
|
133
|
+
* so fail loudly. Only `true`/`false` tokens are treated as ambiguous; any other
|
|
134
|
+
* positional (e.g. a filename) is left as a genuine positional.
|
|
135
|
+
*/
|
|
136
|
+
function assertNoSpaceValuedBoolean(tokens, options) {
|
|
137
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
138
|
+
const tok = tokens[i];
|
|
139
|
+
// `tok.value === undefined` => bare flag (no inline `=value`); applies to
|
|
140
|
+
// declared boolean options only (unknown options are ignored, see `strict`).
|
|
141
|
+
if (tok.kind !== 'option' || tok.value !== undefined || options[tok.name]?.type !== 'boolean') {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const next = tokens[i + 1];
|
|
145
|
+
if (next.kind === 'positional' && (next.value === 'true' || next.value === 'false')) {
|
|
146
|
+
throw new ParseArgsError(`Boolean option --${tok.name} does not take a space-separated value ("${next.value}"); use --${tok.name}=${next.value} or --${next.value === 'false' ? `no-${tok.name}` : tok.name}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
104
150
|
function toNumber(raw, name) {
|
|
105
151
|
const n = Number(raw);
|
|
106
152
|
if (Number.isNaN(n)) {
|
|
@@ -108,6 +154,15 @@ function toNumber(raw, name) {
|
|
|
108
154
|
}
|
|
109
155
|
return n;
|
|
110
156
|
}
|
|
157
|
+
function toBoolean(raw, name) {
|
|
158
|
+
if (typeof raw === 'boolean')
|
|
159
|
+
return raw; // real boolean from --flag / --no-flag / default
|
|
160
|
+
if (raw === 'true')
|
|
161
|
+
return true;
|
|
162
|
+
if (raw === 'false')
|
|
163
|
+
return false;
|
|
164
|
+
throw new ParseArgsError(`Invalid boolean for --${name}: "${raw}"`);
|
|
165
|
+
}
|
|
111
166
|
function buildHelp(options, usage) {
|
|
112
167
|
const lines = [];
|
|
113
168
|
if (usage)
|
|
@@ -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",
|
|
@@ -15,8 +16,8 @@
|
|
|
15
16
|
"yaml": "^2"
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
18
|
-
"typescript": "
|
|
19
|
-
"@naturalcycles/dev-lib": "
|
|
19
|
+
"typescript": "^7",
|
|
20
|
+
"@naturalcycles/dev-lib": "0.0.0"
|
|
20
21
|
},
|
|
21
22
|
"exports": {
|
|
22
23
|
".": "./dist/index.js",
|
|
@@ -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/cli/parseArgs.ts
CHANGED
|
@@ -186,6 +186,7 @@ export function _parseArgs<const O extends CliOptions>(
|
|
|
186
186
|
options: nodeOptions,
|
|
187
187
|
allowPositionals: true,
|
|
188
188
|
allowNegative: true, // native `--no-flag` support for booleans
|
|
189
|
+
tokens: true, // needed to detect the ambiguous `--boolFlag value` space form
|
|
189
190
|
// In non-strict mode, unknown options are collected into `values` but
|
|
190
191
|
// ignored below, since we only read declared options. See `strict` docs.
|
|
191
192
|
strict,
|
|
@@ -202,6 +203,8 @@ export function _parseArgs<const O extends CliOptions>(
|
|
|
202
203
|
process.exit(0)
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
assertNoSpaceValuedBoolean(parsed.tokens, options)
|
|
207
|
+
|
|
205
208
|
const result: Record<string, unknown> = { _: parsed.positionals }
|
|
206
209
|
|
|
207
210
|
for (const [name, def] of Object.entries(options)) {
|
|
@@ -225,11 +228,35 @@ export function _parseArgs<const O extends CliOptions>(
|
|
|
225
228
|
v = Array.isArray(v) ? v : [v]
|
|
226
229
|
}
|
|
227
230
|
|
|
231
|
+
// A non-boolean option passed as a bare flag (`--out` with no value) comes back
|
|
232
|
+
// from node's parseArgs (in non-strict mode) as boolean `true`. Reject it: the
|
|
233
|
+
// user almost certainly forgot the value, and silently coercing `true` (to `1`
|
|
234
|
+
// for numbers, `"true"` for strings, or crashing a `transform`) would hide the
|
|
235
|
+
// mistake. Only arg-sourced values are checked; a boolean `default` is left
|
|
236
|
+
// alone. `def.type === 'boolean'` legitimately produces booleans, so skip it.
|
|
237
|
+
if (fromArgs && def.type !== 'boolean') {
|
|
238
|
+
const bareFlag = Array.isArray(v)
|
|
239
|
+
? v.some(x => typeof x === 'boolean')
|
|
240
|
+
: typeof v === 'boolean'
|
|
241
|
+
if (bareFlag) {
|
|
242
|
+
throw new ParseArgsError(`Missing value for --${name}`)
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
228
246
|
// `transform` owns conversion, so built-in number coercion is skipped for it
|
|
229
247
|
if (def.type === 'number' && !def.transform) {
|
|
230
248
|
v = Array.isArray(v) ? v.map(x => toNumber(x, name)) : toNumber(v, name)
|
|
231
249
|
}
|
|
232
250
|
|
|
251
|
+
// node's parseArgs (in non-strict mode) captures the inline value of
|
|
252
|
+
// `--flag=value` on a boolean option as a string ("false"/"true"), rather than
|
|
253
|
+
// rejecting it as strict mode does. Coerce known tokens so `--flag=false` means
|
|
254
|
+
// boolean false, not a truthy "false" string. Real booleans produced by
|
|
255
|
+
// `--flag` / `--no-flag` (and boolean defaults) pass through untouched.
|
|
256
|
+
if (def.type === 'boolean') {
|
|
257
|
+
v = Array.isArray(v) ? v.map(x => toBoolean(x, name)) : toBoolean(v, name)
|
|
258
|
+
}
|
|
259
|
+
|
|
233
260
|
if (def.choices) {
|
|
234
261
|
const list = Array.isArray(v) ? v : [v]
|
|
235
262
|
for (const x of list) {
|
|
@@ -258,6 +285,34 @@ export function _parseArgs<const O extends CliOptions>(
|
|
|
258
285
|
return result as InferCliArgs<O>
|
|
259
286
|
}
|
|
260
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Reject the ambiguous `--boolFlag value` space form. node never consumes the
|
|
290
|
+
* next token as a boolean's value (getopt convention), so `--arg false` would
|
|
291
|
+
* silently yield `arg: true` and leak "false" into positionals. Unlike the
|
|
292
|
+
* `=value` form (handled by toBoolean) we can't recover the intended value here,
|
|
293
|
+
* so fail loudly. Only `true`/`false` tokens are treated as ambiguous; any other
|
|
294
|
+
* positional (e.g. a filename) is left as a genuine positional.
|
|
295
|
+
*/
|
|
296
|
+
function assertNoSpaceValuedBoolean(
|
|
297
|
+
tokens: NonNullable<ReturnType<typeof parseArgs>['tokens']>,
|
|
298
|
+
options: CliOptions,
|
|
299
|
+
): void {
|
|
300
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
301
|
+
const tok = tokens[i]!
|
|
302
|
+
// `tok.value === undefined` => bare flag (no inline `=value`); applies to
|
|
303
|
+
// declared boolean options only (unknown options are ignored, see `strict`).
|
|
304
|
+
if (tok.kind !== 'option' || tok.value !== undefined || options[tok.name]?.type !== 'boolean') {
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
const next = tokens[i + 1]!
|
|
308
|
+
if (next.kind === 'positional' && (next.value === 'true' || next.value === 'false')) {
|
|
309
|
+
throw new ParseArgsError(
|
|
310
|
+
`Boolean option --${tok.name} does not take a space-separated value ("${next.value}"); use --${tok.name}=${next.value} or --${next.value === 'false' ? `no-${tok.name}` : tok.name}`,
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
261
316
|
function toNumber(raw: unknown, name: string): number {
|
|
262
317
|
const n = Number(raw)
|
|
263
318
|
if (Number.isNaN(n)) {
|
|
@@ -266,6 +321,13 @@ function toNumber(raw: unknown, name: string): number {
|
|
|
266
321
|
return n
|
|
267
322
|
}
|
|
268
323
|
|
|
324
|
+
function toBoolean(raw: unknown, name: string): boolean {
|
|
325
|
+
if (typeof raw === 'boolean') return raw // real boolean from --flag / --no-flag / default
|
|
326
|
+
if (raw === 'true') return true
|
|
327
|
+
if (raw === 'false') return false
|
|
328
|
+
throw new ParseArgsError(`Invalid boolean for --${name}: "${raw}"`)
|
|
329
|
+
}
|
|
330
|
+
|
|
269
331
|
function buildHelp(options: CliOptions, usage?: string): string {
|
|
270
332
|
const lines: string[] = []
|
|
271
333
|
if (usage) lines.push(usage, '')
|
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
|
+
}
|