/
jwt-verifier.ts
262 lines (238 loc) · 7.5 KB
/
jwt-verifier.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
import { strict as assert } from 'assert';
import { Agent as HttpAgent } from 'http';
import { Agent as HttpsAgent } from 'https';
import { jwtVerify } from 'jose';
import type { JWTPayload, JWSHeaderParameters } from 'jose';
import { InvalidTokenError } from 'oauth2-bearer';
import discovery from './discovery';
import getKeyFn from './get-key-fn';
import validate, { defaultValidators, Validators } from './validate';
export interface JwtVerifierOptions {
/**
* Base url, used to find the authorization server's app metadata per
* https://datatracker.ietf.org/doc/html/rfc8414
* You can pass a full url including `.well-known` if your discovery lives at
* a non standard path.
* REQUIRED (if you don't include {@Link AuthOptions.jwksUri} and
* {@Link AuthOptions.issuer})
* You can also provide the `ISSUER_BASE_URL` environment variable.
*/
issuerBaseURL?: string;
/**
* Expected JWT "aud" (Audience) Claim value(s).
* REQUIRED: You can also provide the `AUDIENCE` environment variable.
*/
audience?: string | string[];
/**
* Expected JWT "iss" (Issuer) Claim value.
* REQUIRED (if you don't include {@Link AuthOptions.issuerBaseURL})
* You can also provide the `ISSUER` environment variable.
*/
issuer?: string;
/**
* Url for the authorization server's JWKS to find the public key to verify
* an Access Token JWT signed with an asymmetric algorithm.
* REQUIRED (if you don't include {@Link AuthOptions.issuerBaseURL})
* You can also provide the `JWKS_URI` environment variable.
*/
jwksUri?: string;
/**
* An instance of http.Agent or https.Agent to pass to the http.get or
* https.get method options. Use when behind an http(s) proxy.
*/
agent?: HttpAgent | HttpsAgent;
/**
* Duration in ms for which no more HTTP requests to the JWKS Uri endpoint
* will be triggered after a previous successful fetch.
* Default is 30000.
*/
cooldownDuration?: number;
/**
* Timeout in ms for HTTP requests to the JWKS and Discovery endpoint. When
* reached the request will be aborted.
* Default is 5000.
*/
timeoutDuration?: number;
/**
* Maximum time (in milliseconds) between successful HTTP requests to the
* JWKS and Discovery endpoint.
* Default is 600000 (10 minutes).
*/
cacheMaxAge?: number;
/**
* Pass in custom validators to override the existing validation behavior on
* standard claims or add new validation behavior on custom claims.
*
* ```js
* {
* validators: {
* // Disable issuer validation by passing `false`
* iss: false,
* // Add validation for a custom claim to equal a passed in string
* org_id: 'my_org_123'
* // Add validation for a custom claim, by passing in a function that
* // accepts:
* // roles: the value of the claim
* // claims: an object containing the JWTPayload
* // header: an object representing the JWTHeader
* roles: (roles, claims, header) => roles.includes('editor') && claims.isAdmin
* }
* }
* ```
*/
validators?: Partial<Validators>;
/**
* Clock tolerance (in secs) used when validating the `exp` and `iat` claim.
* Defaults to 5 secs.
*/
clockTolerance?: number;
/**
* Maximum age (in secs) from when a token was issued to when it can no longer
* be accepted.
*/
maxTokenAge?: number;
/**
* If set to `true` the token validation will strictly follow
* 'JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens'
* https://datatracker.ietf.org/doc/html/draft-ietf-oauth-access-token-jwt-12
* Defaults to false.
*/
strict?: boolean;
/**
* Secret to verify an Access Token JWT signed with a symmetric algorithm.
* By default this SDK validates tokens signed with asymmetric algorithms.
*/
secret?: string;
/**
* You must provide this if your tokens are signed with symmetric algorithms
* and it must be one of HS256, HS384 or HS512.
* You may provide this if your tokens are signed with asymmetric algorithms
* and, if provided, it must be one of RS256, RS384, RS512, PS256, PS384,
* PS512, ES256, ES256K, ES384, ES512 or EdDSA (case-sensitive).
*/
tokenSigningAlg?: string;
}
export interface VerifyJwtResult {
/**
* The Access Token JWT header.
*/
header: JWSHeaderParameters;
/**
* The Access Token JWT payload.
*/
payload: JWTPayload;
/**
* The raw Access Token JWT
*/
token: string;
}
export type VerifyJwt = (jwt: string) => Promise<VerifyJwtResult>;
const ASYMMETRIC_ALGS = [
'RS256',
'RS384',
'RS512',
'PS256',
'PS384',
'PS512',
'ES256',
'ES256K',
'ES384',
'ES512',
'EdDSA',
];
const SYMMETRIC_ALGS = ['HS256', 'HS384', 'HS512'];
const jwtVerifier = ({
issuerBaseURL = process.env.ISSUER_BASE_URL as string,
jwksUri = process.env.JWKS_URI as string,
issuer = process.env.ISSUER as string,
audience = process.env.AUDIENCE as string,
secret = process.env.SECRET as string,
tokenSigningAlg = process.env.TOKEN_SIGNING_ALG as string,
agent,
cooldownDuration = 30000,
timeoutDuration = 5000,
cacheMaxAge = 600000,
clockTolerance = 5,
maxTokenAge,
strict = false,
validators: customValidators,
}: JwtVerifierOptions): VerifyJwt => {
let validators: Validators;
let allowedSigningAlgs: string[] | undefined;
assert(
issuerBaseURL || (issuer && jwksUri) || (issuer && secret),
"You must provide an 'issuerBaseURL', an 'issuer' and 'jwksUri' or an 'issuer' and 'secret'"
);
assert(
!(secret && jwksUri),
"You must not provide both a 'secret' and 'jwksUri'"
);
assert(audience, "An 'audience' is required to validate the 'aud' claim");
assert(
!secret || (secret && tokenSigningAlg),
"You must provide a 'tokenSigningAlg' for validating symmetric algorithms"
);
assert(
secret || !tokenSigningAlg || ASYMMETRIC_ALGS.includes(tokenSigningAlg),
`You must supply one of ${ASYMMETRIC_ALGS.join(
', '
)} for 'tokenSigningAlg' to validate asymmetrically signed tokens`
);
assert(
!secret || (tokenSigningAlg && SYMMETRIC_ALGS.includes(tokenSigningAlg)),
`You must supply one of ${SYMMETRIC_ALGS.join(
', '
)} for 'tokenSigningAlg' to validate symmetrically signed tokens`
);
const getDiscovery = discovery({
issuerBaseURL,
agent,
timeoutDuration,
cacheMaxAge,
});
const getKeyFnGetter = getKeyFn({
agent,
cooldownDuration,
timeoutDuration,
cacheMaxAge,
secret,
});
return async (jwt: string) => {
try {
if (issuerBaseURL) {
const {
jwks_uri: discoveredJwksUri,
issuer: discoveredIssuer,
id_token_signing_alg_values_supported:
idTokenSigningAlgValuesSupported,
} = await getDiscovery();
jwksUri = jwksUri || discoveredJwksUri;
issuer = issuer || discoveredIssuer;
allowedSigningAlgs = idTokenSigningAlgValuesSupported;
}
validators ||= {
...defaultValidators(
issuer,
audience,
clockTolerance,
maxTokenAge,
strict,
allowedSigningAlgs,
tokenSigningAlg
),
...customValidators,
};
const { payload, protectedHeader: header } = await jwtVerify(
jwt,
getKeyFnGetter(jwksUri),
{ clockTolerance }
);
await validate(payload, header, validators);
return { payload, header, token: jwt };
} catch (e) {
throw new InvalidTokenError(e.message);
}
};
};
export default jwtVerifier;
export { JWTPayload, JWSHeaderParameters };