bdy 1.22.84 → 1.22.85-beta
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/distTs/package.json +1 -1
- package/distTs/src/agent/socket/client.js +2 -2
- package/distTs/src/api/client.js +4 -3
- package/distTs/src/command/login.js +11 -2
- package/distTs/src/tunnel/api/buddy.js +1 -1
- package/distTs/src/tunnel/config.js +241 -0
- package/distTs/src/tunnel/http/auth.js +404 -0
- package/distTs/src/tunnel/http/jwks.js +84 -0
- package/distTs/src/tunnel/tunnel.js +134 -820
- package/distTs/src/utils.js +49 -0
- package/package.json +1 -1
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
7
|
+
const utils_1 = require("../../utils");
|
|
8
|
+
const logger_1 = __importDefault(require("../../logger"));
|
|
9
|
+
const texts_1 = require("../../texts");
|
|
10
|
+
const tunnel_1 = require("../../types/tunnel");
|
|
11
|
+
const jwks_1 = __importDefault(require("./jwks"));
|
|
12
|
+
const COOKIE_NAME = 'jwt_token';
|
|
13
|
+
const AUTH_ME_PATH = '/.buddy/auth/me';
|
|
14
|
+
const AUTH_USER_CACHE_TTL = 60 * 1000;
|
|
15
|
+
const AUTH_STATE_TTL = 10 * 60 * 1000;
|
|
16
|
+
class TunnelHttpAuth {
|
|
17
|
+
tunnel;
|
|
18
|
+
jwks;
|
|
19
|
+
clients;
|
|
20
|
+
userCache;
|
|
21
|
+
// OAuth `state` is an opaque random id; the sensitive url/verifier live here
|
|
22
|
+
// in-process (never leave the tunnel), keyed by that id and bounded by TTL.
|
|
23
|
+
stateStore;
|
|
24
|
+
constructor(tunnel) {
|
|
25
|
+
this.tunnel = tunnel;
|
|
26
|
+
this.jwks = new jwks_1.default();
|
|
27
|
+
this.clients = {};
|
|
28
|
+
this.userCache = {};
|
|
29
|
+
this.stateStore = {};
|
|
30
|
+
}
|
|
31
|
+
_safeEqual(a, b) {
|
|
32
|
+
// Hash both sides to a fixed length so neither the byte contents nor the
|
|
33
|
+
// input length leak through comparison timing.
|
|
34
|
+
const ah = node_crypto_1.default.createHash('sha256').update(String(a)).digest();
|
|
35
|
+
const bh = node_crypto_1.default.createHash('sha256').update(String(b)).digest();
|
|
36
|
+
return node_crypto_1.default.timingSafeEqual(ah, bh);
|
|
37
|
+
}
|
|
38
|
+
async basicAuth(req, res) {
|
|
39
|
+
if (this.tunnel.authType !== tunnel_1.TUNNEL_HTTP_AUTH_TYPE.BASIC)
|
|
40
|
+
return true;
|
|
41
|
+
const user = require('basic-auth')(req);
|
|
42
|
+
if (user) {
|
|
43
|
+
// compute both comparisons unconditionally so neither the credentials
|
|
44
|
+
// nor which field mismatched leak through timing
|
|
45
|
+
const nameOk = this._safeEqual(user.name || '', this.tunnel.login || '');
|
|
46
|
+
const passOk = this._safeEqual(user.pass || '', this.tunnel.password || '');
|
|
47
|
+
if (nameOk && passOk) {
|
|
48
|
+
// tunnel consumed the credentials; don't leak them to the target
|
|
49
|
+
delete req.headers.authorization;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (await this._checkPat(req, res, false)) {
|
|
54
|
+
// tunnel consumed the credentials; don't leak them to the target
|
|
55
|
+
delete req.headers.authorization;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
logger_1.default.debug(texts_1.LOG_TUNNEL_HTTP_WRON_AUTH);
|
|
59
|
+
this.tunnel.httpEndFast(req, res, 401, 'Unauthorised', {
|
|
60
|
+
'WWW-Authenticate': 'Basic real="Buddy"',
|
|
61
|
+
});
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
async buddyAuth(req, res) {
|
|
65
|
+
if (this.tunnel.authType !== tunnel_1.TUNNEL_HTTP_AUTH_TYPE.BUDDY)
|
|
66
|
+
return true;
|
|
67
|
+
const { searchParams, pathname, search } = new URL(req.url, 'https://abc.com');
|
|
68
|
+
const code = searchParams.get('code') || '';
|
|
69
|
+
const state = searchParams.get('state') || '';
|
|
70
|
+
const token = searchParams.get('access_token') || '';
|
|
71
|
+
const { url, verifier } = this._consumeState(state);
|
|
72
|
+
const redirectByAuthToken = this._checkTokenRedirect(req, res, code, state, token, url);
|
|
73
|
+
if (redirectByAuthToken) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const authByToken = await this._checkToken(code, verifier || '', token, req, res);
|
|
77
|
+
if (authByToken) {
|
|
78
|
+
if (url) {
|
|
79
|
+
this.tunnel.httpEndFast(req, res, 302, 'Found', {
|
|
80
|
+
location: url,
|
|
81
|
+
});
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (url && verifier && (code || token)) {
|
|
87
|
+
this.tunnel.httpEndFast(req, res, 404, 'Not found');
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (await this._checkPat(req, res, true)) {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
this._redirectToBuddyAuth(pathname, search, req, res);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
async buddyAuthMe(req, res) {
|
|
97
|
+
if (this.tunnel.authType !== tunnel_1.TUNNEL_HTTP_AUTH_TYPE.BUDDY)
|
|
98
|
+
return false;
|
|
99
|
+
const url = req.url || '';
|
|
100
|
+
if (url !== AUTH_ME_PATH && !url.startsWith(`${AUTH_ME_PATH}?`)) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (!this.tunnel.httpRateLimit(req, res))
|
|
104
|
+
return true;
|
|
105
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
106
|
+
this.tunnel.httpEndFastJson(req, res, 405, { error: 'Method not allowed' }, {
|
|
107
|
+
allow: 'GET, HEAD',
|
|
108
|
+
});
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
const data = await this._verifyCookie(req);
|
|
112
|
+
const user = data ? await this._fetchUser(data.token) : null;
|
|
113
|
+
if (!user) {
|
|
114
|
+
this.tunnel.httpEndFastJson(req, res, 401, { error: 'Unauthorised' });
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
this.tunnel.httpEndFastJson(req, res, 200, user);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
async _checkPat(req, res, setCookie) {
|
|
121
|
+
const user = require('basic-auth')(req);
|
|
122
|
+
if (!user || !user.name || !user.pass)
|
|
123
|
+
return false;
|
|
124
|
+
const client = this._getClient();
|
|
125
|
+
let ipAddress;
|
|
126
|
+
if (req.httpVersion === '2.0') {
|
|
127
|
+
ipAddress = req.socket.stream ? req.socket.stream.remoteAddress : '::1';
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
ipAddress = req.socket.remoteAddress;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const { statusCode, body } = await client.request({
|
|
134
|
+
path: `/tunnel/auth/pat`,
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
'content-type': 'application/json',
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
id: this.tunnel.id,
|
|
141
|
+
agentId: this.tunnel.agent.id,
|
|
142
|
+
login: user.name,
|
|
143
|
+
password: user.pass,
|
|
144
|
+
ipAddress,
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
if (statusCode !== 200) {
|
|
148
|
+
await body.dump();
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const result = (await body.json());
|
|
152
|
+
if (!result.success)
|
|
153
|
+
return false;
|
|
154
|
+
if (setCookie)
|
|
155
|
+
this._setAuthCookie(res, result.token);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
_getClient() {
|
|
163
|
+
const host = this.tunnel.agent.host;
|
|
164
|
+
if (!this.clients[host]) {
|
|
165
|
+
const { Pool: UndiciPool } = require('undici');
|
|
166
|
+
this.clients[host] = new UndiciPool(host, {
|
|
167
|
+
connect: {
|
|
168
|
+
rejectUnauthorized: !(0, utils_1.isLocalHost)(host),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return this.clients[host];
|
|
173
|
+
}
|
|
174
|
+
async _exchangeAuthCode(code, verifier) {
|
|
175
|
+
const client = this._getClient();
|
|
176
|
+
try {
|
|
177
|
+
const r = await client.request({
|
|
178
|
+
path: `/external-api/auth/token?grant_type=authorization_code`,
|
|
179
|
+
method: 'POST',
|
|
180
|
+
headers: {
|
|
181
|
+
'content-type': 'application/json',
|
|
182
|
+
},
|
|
183
|
+
body: JSON.stringify({
|
|
184
|
+
code,
|
|
185
|
+
verifier,
|
|
186
|
+
}),
|
|
187
|
+
});
|
|
188
|
+
const o = (await r.body.json());
|
|
189
|
+
if (o.accessToken) {
|
|
190
|
+
return o.accessToken;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// do nothing
|
|
195
|
+
}
|
|
196
|
+
return '';
|
|
197
|
+
}
|
|
198
|
+
async _parseToken(token) {
|
|
199
|
+
const msg = 'Invalid authorization token';
|
|
200
|
+
if (!token) {
|
|
201
|
+
throw new Error(msg);
|
|
202
|
+
}
|
|
203
|
+
const decoded = require('jsonwebtoken').decode(token, {
|
|
204
|
+
complete: true,
|
|
205
|
+
});
|
|
206
|
+
const data = decoded?.payload;
|
|
207
|
+
if (!data?.iss) {
|
|
208
|
+
throw new Error(msg);
|
|
209
|
+
}
|
|
210
|
+
// Pin to the agent host's issuer when mapped; otherwise only accept a known
|
|
211
|
+
// Buddy issuer (bounds the JWKS fetch to trusted origins).
|
|
212
|
+
const expectedIssuer = (0, utils_1.getOidcIssuerByAppHost)(this.tunnel.agent.host);
|
|
213
|
+
if (expectedIssuer) {
|
|
214
|
+
if (data.iss !== expectedIssuer) {
|
|
215
|
+
throw new Error(msg);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else if (!(0, utils_1.isKnownOidcIssuer)(data.iss)) {
|
|
219
|
+
throw new Error(msg);
|
|
220
|
+
}
|
|
221
|
+
const issuer = expectedIssuer || data.iss;
|
|
222
|
+
const cert = await this.jwks.getKey(issuer, decoded.header?.kid);
|
|
223
|
+
if (!cert) {
|
|
224
|
+
throw new Error(msg);
|
|
225
|
+
}
|
|
226
|
+
return new Promise((resolve, reject) => {
|
|
227
|
+
require('jsonwebtoken').verify(token, cert, {
|
|
228
|
+
complete: true,
|
|
229
|
+
algorithms: ['RS256'],
|
|
230
|
+
issuer,
|
|
231
|
+
audience: 'tunnels',
|
|
232
|
+
subject: 'tunnel_read',
|
|
233
|
+
}, (err, data) => {
|
|
234
|
+
if (err || !data) {
|
|
235
|
+
reject(new Error(msg));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const payload = data.payload;
|
|
239
|
+
if (!payload.tunnelId || !payload.userId) {
|
|
240
|
+
reject(new Error(msg));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
resolve({
|
|
244
|
+
tunnelId: payload.tunnelId,
|
|
245
|
+
userId: payload.userId,
|
|
246
|
+
token,
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
_setAuthCookie(res, token) {
|
|
252
|
+
res.setHeader('set-cookie', require('cookie').serialize(COOKIE_NAME, token, {
|
|
253
|
+
maxAge: 3600,
|
|
254
|
+
path: '/',
|
|
255
|
+
secure: true,
|
|
256
|
+
httpOnly: true,
|
|
257
|
+
sameSite: 'lax',
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
_checkTokenRedirect(req, res, code, state, accessToken, url) {
|
|
261
|
+
if ((code || accessToken) && state && url) {
|
|
262
|
+
const currentHost = req.headers['x-forwarded-host'] ||
|
|
263
|
+
req.headers.host ||
|
|
264
|
+
req.headers[':authority'] ||
|
|
265
|
+
'';
|
|
266
|
+
const originalHost = new URL(url).host;
|
|
267
|
+
if (originalHost !== currentHost) {
|
|
268
|
+
const location = new URL(`https://${originalHost}`);
|
|
269
|
+
location.searchParams.set('state', state);
|
|
270
|
+
if (code)
|
|
271
|
+
location.searchParams.set('code', code);
|
|
272
|
+
else if (accessToken)
|
|
273
|
+
location.searchParams.set('access_token', accessToken);
|
|
274
|
+
this.tunnel.httpEndFast(req, res, 302, 'Found', {
|
|
275
|
+
location: location.href,
|
|
276
|
+
});
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
async _verifyToken(token) {
|
|
283
|
+
if (!token)
|
|
284
|
+
return null;
|
|
285
|
+
try {
|
|
286
|
+
const data = await this._parseToken(token);
|
|
287
|
+
if (data.tunnelId !== this.tunnel.id || !data.userId)
|
|
288
|
+
return null;
|
|
289
|
+
return data;
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
async _verifyCookie(req) {
|
|
296
|
+
const cookies = require('cookie').parse(req.headers.cookie || '');
|
|
297
|
+
return this._verifyToken(cookies[COOKIE_NAME] || '');
|
|
298
|
+
}
|
|
299
|
+
async _checkToken(code, verifier, token, req, res) {
|
|
300
|
+
if (code && verifier && !token) {
|
|
301
|
+
token = await this._exchangeAuthCode(code, verifier);
|
|
302
|
+
}
|
|
303
|
+
if (!token) {
|
|
304
|
+
return !!(await this._verifyCookie(req));
|
|
305
|
+
}
|
|
306
|
+
if (!(await this._verifyToken(token))) {
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
this._setAuthCookie(res, token);
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
_sweepExpired(map, now) {
|
|
313
|
+
Object.keys(map).forEach((k) => {
|
|
314
|
+
if (map[k].expiresAt <= now)
|
|
315
|
+
delete map[k];
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
_createState(url, verifier) {
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
this._sweepExpired(this.stateStore, now);
|
|
321
|
+
const id = node_crypto_1.default.randomBytes(24).toString('base64url');
|
|
322
|
+
this.stateStore[id] = { url, verifier, expiresAt: now + AUTH_STATE_TTL };
|
|
323
|
+
return id;
|
|
324
|
+
}
|
|
325
|
+
_consumeState(state) {
|
|
326
|
+
if (state) {
|
|
327
|
+
const entry = this.stateStore[state];
|
|
328
|
+
// Not deleted on read: the wrong-host redirect in _checkTokenRedirect
|
|
329
|
+
// re-enters with the same state, so validity is bounded by TTL instead.
|
|
330
|
+
if (entry && entry.expiresAt > Date.now()) {
|
|
331
|
+
return { url: entry.url, verifier: entry.verifier };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return { url: null, verifier: null };
|
|
335
|
+
}
|
|
336
|
+
_redirectToBuddyAuth(pathname, search, req, res) {
|
|
337
|
+
let host = this.tunnel.agent.host;
|
|
338
|
+
if (/^https:\/\/\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
|
339
|
+
host = 'https://app.local.io';
|
|
340
|
+
}
|
|
341
|
+
const verifier = require('uuid').v4();
|
|
342
|
+
const challenge = node_crypto_1.default
|
|
343
|
+
.createHash('sha256')
|
|
344
|
+
.update(verifier)
|
|
345
|
+
.digest('hex');
|
|
346
|
+
const callback = `https://${this.tunnel.subdomain}.${(this.tunnel.region || '').toLowerCase()}-${this.tunnel.sshId}.${this.tunnel.domain}`;
|
|
347
|
+
let redirect;
|
|
348
|
+
if (req.headers['x-forwarded-host']) {
|
|
349
|
+
if (req.headers['x-forwarded-proto'] === 'http') {
|
|
350
|
+
redirect = 'http://';
|
|
351
|
+
}
|
|
352
|
+
else {
|
|
353
|
+
redirect = 'https://';
|
|
354
|
+
}
|
|
355
|
+
redirect += req.headers['x-forwarded-host'];
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
redirect = callback;
|
|
359
|
+
}
|
|
360
|
+
redirect += pathname;
|
|
361
|
+
if (search)
|
|
362
|
+
redirect += search;
|
|
363
|
+
const stateId = this._createState(redirect, verifier);
|
|
364
|
+
let url = `${host}/tunnel/auth?redirect_url=${encodeURIComponent(callback)}&id=${encodeURIComponent(this.tunnel.id)}&agentId=${encodeURIComponent(this.tunnel.agent.id)}&challenge=${encodeURIComponent(challenge)}&state=${encodeURIComponent(stateId)}`;
|
|
365
|
+
if (/app\.local\.io/.test(host)) {
|
|
366
|
+
url += '&response_type=token';
|
|
367
|
+
}
|
|
368
|
+
this.tunnel.httpEndFast(req, res, 302, 'Found', {
|
|
369
|
+
location: url,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
async _fetchUser(token) {
|
|
373
|
+
const key = node_crypto_1.default.createHash('sha256').update(token).digest('hex');
|
|
374
|
+
const cached = this.userCache[key];
|
|
375
|
+
if (cached && cached.expiresAt > Date.now())
|
|
376
|
+
return cached.user;
|
|
377
|
+
const client = this._getClient();
|
|
378
|
+
try {
|
|
379
|
+
const { statusCode, body } = await client.request({
|
|
380
|
+
path: `/external-api/tunnel/user`,
|
|
381
|
+
method: 'GET',
|
|
382
|
+
headers: {
|
|
383
|
+
authorization: `Bearer ${token}`,
|
|
384
|
+
},
|
|
385
|
+
});
|
|
386
|
+
if (statusCode !== 200) {
|
|
387
|
+
await body.dump();
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
const user = (await body.json());
|
|
391
|
+
const now = Date.now();
|
|
392
|
+
this._sweepExpired(this.userCache, now);
|
|
393
|
+
this.userCache[key] = {
|
|
394
|
+
user,
|
|
395
|
+
expiresAt: now + AUTH_USER_CACHE_TTL,
|
|
396
|
+
};
|
|
397
|
+
return user;
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
exports.default = TunnelHttpAuth;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
7
|
+
const JWKS_PATH = '/.well-known/jwks';
|
|
8
|
+
// Keys rotate rarely and a rotation ships a new kid (which triggers an
|
|
9
|
+
// out-of-band refetch bounded by JWKS_RETRY_TTL), so the success TTL can be
|
|
10
|
+
// long. JWKS_RETRY_TTL also throttles refetch on fetch failure / unknown kid.
|
|
11
|
+
const JWKS_CACHE_TTL = 24 * 60 * 60 * 1000;
|
|
12
|
+
const JWKS_RETRY_TTL = 60 * 1000;
|
|
13
|
+
class JwksCache {
|
|
14
|
+
cache;
|
|
15
|
+
constructor() {
|
|
16
|
+
this.cache = {};
|
|
17
|
+
}
|
|
18
|
+
async getKey(issuer, kid) {
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const refresh = async (fallbackKeys) => {
|
|
21
|
+
const jwks = await this._fetch(issuer);
|
|
22
|
+
const e = {
|
|
23
|
+
keys: jwks ? jwks.keys : fallbackKeys,
|
|
24
|
+
keyObjects: {},
|
|
25
|
+
fetchedAt: now,
|
|
26
|
+
expiresAt: now + (jwks ? JWKS_CACHE_TTL : JWKS_RETRY_TTL),
|
|
27
|
+
};
|
|
28
|
+
this.cache[issuer] = e;
|
|
29
|
+
return e;
|
|
30
|
+
};
|
|
31
|
+
let entry = this.cache[issuer];
|
|
32
|
+
if (!entry || entry.expiresAt <= now) {
|
|
33
|
+
entry = await refresh([]);
|
|
34
|
+
}
|
|
35
|
+
const keyObject = entry.keyObjects[kid || ''];
|
|
36
|
+
if (keyObject)
|
|
37
|
+
return keyObject;
|
|
38
|
+
let jwk = this._select(entry.keys, kid);
|
|
39
|
+
if (!jwk && now - entry.fetchedAt > JWKS_RETRY_TTL) {
|
|
40
|
+
// unknown kid - keys may have rotated; refetch at most once per JWKS_RETRY_TTL
|
|
41
|
+
entry = await refresh(entry.keys);
|
|
42
|
+
jwk = this._select(entry.keys, kid);
|
|
43
|
+
}
|
|
44
|
+
if (!jwk)
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
const key = node_crypto_1.default.createPublicKey({ key: jwk, format: 'jwk' });
|
|
48
|
+
entry.keyObjects[kid || ''] = key;
|
|
49
|
+
return key;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async _fetch(issuer) {
|
|
56
|
+
try {
|
|
57
|
+
const { request } = require('undici');
|
|
58
|
+
const { statusCode, body } = await request(`${issuer}${JWKS_PATH}`, {
|
|
59
|
+
headers: {
|
|
60
|
+
accept: 'application/json',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
if (statusCode !== 200) {
|
|
64
|
+
await body.dump();
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const o = (await body.json());
|
|
68
|
+
if (!o || !Array.isArray(o.keys))
|
|
69
|
+
return null;
|
|
70
|
+
return o;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
_select(keys, kid) {
|
|
77
|
+
if (!keys || !keys.length)
|
|
78
|
+
return null;
|
|
79
|
+
if (kid)
|
|
80
|
+
return keys.find((k) => k.kid === kid) || null;
|
|
81
|
+
return keys.find((k) => k.use === 'sig') || keys[0];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
exports.default = JwksCache;
|