@valbuild/next 0.119.0 → 0.120.2

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.
@@ -150,6 +150,10 @@ function initValServer(valModules, config, nextConfig) {
150
150
  * - **Nothing is read from the payload before the signature verifies.** Claims
151
151
  * from an unverified token are attacker input.
152
152
  * - **Keys come only from the configured issuer's JWKS**, never from the token.
153
+ * The key set is cached, but a token naming a `kid` the cache does not hold
154
+ * provokes one rate-limited refetch rather than a refusal — see
155
+ * {@link UNKNOWN_KID_REFETCH_INTERVAL_MS}. Without that, this server's own
156
+ * cache turns any key rotation into an outage lasting the rest of the TTL.
153
157
  * - **ECDSA JWS signatures are raw `r||s`** (RFC 7518), not DER, which is what
154
158
  * `dsaEncoding: "ieee-p1363"` below is for. Omit it and every valid signature
155
159
  * is rejected — or worse, a future change makes it accept the wrong thing.
@@ -166,6 +170,21 @@ var JWKS_TTL_MS = 5 * 60 * 1000;
166
170
  * it.
167
171
  */
168
172
  var JWKS_ERROR_TTL_MS = 30 * 1000;
173
+ /**
174
+ * The floor between two refetches provoked by an unknown `kid`.
175
+ *
176
+ * A token naming a key this process has not seen is the one case where the TTL
177
+ * is the wrong answer: the issuer may have just rotated, and refusing for the
178
+ * rest of the five minutes turns a rotation into an outage. So an unknown `kid`
179
+ * bypasses the TTL — but only this often, because the `kid` comes from the
180
+ * token and the token comes from whoever is calling. Without a floor, a stream
181
+ * of random `kid`s would be a way to make this server hammer its own issuer.
182
+ *
183
+ * Note what the floor costs when it bites: a refusal, for a token that would
184
+ * have verified, for at most this long. That is the same failure the TTL used
185
+ * to guarantee for five minutes, so the trade only ever improves.
186
+ */
187
+ var UNKNOWN_KID_REFETCH_INTERVAL_MS = 30 * 1000;
169
188
  /**
170
189
  * One cache per issuer, and it has to outlive the request or it is not a cache:
171
190
  * a fetch per tool call would put a network round trip in front of every read.
@@ -173,6 +192,15 @@ var JWKS_ERROR_TTL_MS = 30 * 1000;
173
192
  var jwksCache = new Map();
174
193
  /** Concurrent misses share one fetch rather than starting several. */
175
194
  var inFlight = new Map();
195
+ /**
196
+ * When an unknown `kid` last made us go back to the issuer, per issuer.
197
+ *
198
+ * Separate from the cache because it is a rate limit rather than a cache: it
199
+ * records an *attempt*, so a refetch that found nothing new still spends the
200
+ * window. Keyed by issuer, not by `kid`, or an attacker would simply use a new
201
+ * one each time.
202
+ */
203
+ var unknownKidRefetchAtMs = new Map();
176
204
  function jwksUrl(issuer) {
177
205
  // Not discovered from the issuer's metadata document, deliberately:
178
206
  // discovery would mean one more request on the hot path and one more thing
@@ -183,105 +211,211 @@ function jwksUrl(issuer) {
183
211
  function loadJwks(_x, _x2) {
184
212
  return _loadJwks.apply(this, arguments);
185
213
  }
214
+ /**
215
+ * Go back to the issuer because the token named a key we do not have.
216
+ *
217
+ * Rate-limited, and the limit is the whole reason this is not just a call to
218
+ * {@link fetchJwks}: the `kid` that triggers it is attacker-controlled. Returns
219
+ * `null` when the window has not elapsed, which the caller reads as "nothing
220
+ * new to try" rather than as a failure.
221
+ *
222
+ * The limit is on *starting* a fetch, not on benefiting from one. A fetch
223
+ * already in flight is joined whatever the window says, because joining it
224
+ * costs the issuer nothing — and the case that matters is precisely a burst:
225
+ * at a rotation, many requests arrive at once carrying the same new `kid`, and
226
+ * refusing all but the first would be the outage this whole path exists to
227
+ * prevent, merely shortened from five minutes to thirty seconds.
228
+ *
229
+ * Only ever called with a successfully-fetched key set in hand. An error entry
230
+ * has its own, shorter TTL and its own recovery, and letting an unknown `kid`
231
+ * shortcut it would hand an unreachable issuer a retry storm.
232
+ */
186
233
  function _loadJwks() {
187
- _loadJwks = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2(config, nowMs) {
188
- var _config$fetchImpl;
189
- var url, cached, age, ttl, existing, fetchImpl, pending, entry;
190
- return _regenerator().w(function (_context2) {
191
- while (1) switch (_context2.p = _context2.n) {
234
+ _loadJwks = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee(config, nowMs) {
235
+ var url, cached, age, ttl, _t;
236
+ return _regenerator().w(function (_context) {
237
+ while (1) switch (_context.n) {
192
238
  case 0:
193
239
  url = jwksUrl(config.issuer);
194
240
  cached = jwksCache.get(url);
195
241
  if (!cached) {
196
- _context2.n = 1;
242
+ _context.n = 1;
197
243
  break;
198
244
  }
199
245
  age = cached.status === "keys" ? nowMs - cached.fetchedAtMs : nowMs - cached.failedAtMs;
200
246
  ttl = cached.status === "keys" ? JWKS_TTL_MS : JWKS_ERROR_TTL_MS;
201
247
  if (!(age < ttl)) {
202
- _context2.n = 1;
248
+ _context.n = 1;
203
249
  break;
204
250
  }
205
- return _context2.a(2, cached);
251
+ return _context.a(2, {
252
+ entry: cached,
253
+ fromCache: true
254
+ });
206
255
  case 1:
256
+ _context.n = 2;
257
+ return fetchJwks(config, nowMs);
258
+ case 2:
259
+ _t = _context.v;
260
+ return _context.a(2, {
261
+ entry: _t,
262
+ fromCache: false
263
+ });
264
+ }
265
+ }, _callee);
266
+ }));
267
+ return _loadJwks.apply(this, arguments);
268
+ }
269
+ function refetchForUnknownKid(_x3, _x4) {
270
+ return _refetchForUnknownKid.apply(this, arguments);
271
+ }
272
+ /**
273
+ * Fetch the key set, ignoring whatever is cached, and cache the result.
274
+ *
275
+ * Shares {@link inFlight} with every other caller, so a forced refetch that
276
+ * lands during an ordinary miss joins it rather than opening a second request.
277
+ * Only the caller that *starts* a fetch writes the cache — a joiner returns the
278
+ * shared promise above — so `keepCacheOnError` is a property of the fetch, not a
279
+ * race between callers.
280
+ */
281
+ function _refetchForUnknownKid() {
282
+ _refetchForUnknownKid = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2(config, nowMs) {
283
+ var url, existing, lastAttemptMs;
284
+ return _regenerator().w(function (_context2) {
285
+ while (1) switch (_context2.n) {
286
+ case 0:
287
+ url = jwksUrl(config.issuer);
207
288
  existing = inFlight.get(url);
208
289
  if (!existing) {
209
- _context2.n = 2;
290
+ _context2.n = 1;
210
291
  break;
211
292
  }
212
293
  return _context2.a(2, existing);
294
+ case 1:
295
+ lastAttemptMs = unknownKidRefetchAtMs.get(url);
296
+ if (!(lastAttemptMs !== undefined && nowMs - lastAttemptMs < UNKNOWN_KID_REFETCH_INTERVAL_MS)) {
297
+ _context2.n = 2;
298
+ break;
299
+ }
300
+ return _context2.a(2, null);
213
301
  case 2:
302
+ // Recorded before the await, so that once this fetch has finished the window
303
+ // is already closed against the next unknown `kid`.
304
+ unknownKidRefetchAtMs.set(url, nowMs);
305
+ return _context2.a(2, fetchJwks(config, nowMs, {
306
+ keepCacheOnError: true
307
+ }));
308
+ }
309
+ }, _callee2);
310
+ }));
311
+ return _refetchForUnknownKid.apply(this, arguments);
312
+ }
313
+ function fetchJwks(_x5, _x6) {
314
+ return _fetchJwks.apply(this, arguments);
315
+ }
316
+ function _fetchJwks() {
317
+ _fetchJwks = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee4(config, nowMs) {
318
+ var _config$fetchImpl;
319
+ var _ref,
320
+ _ref$keepCacheOnError,
321
+ keepCacheOnError,
322
+ url,
323
+ existing,
324
+ fetchImpl,
325
+ pending,
326
+ entry,
327
+ cached,
328
+ _args4 = arguments;
329
+ return _regenerator().w(function (_context4) {
330
+ while (1) switch (_context4.p = _context4.n) {
331
+ case 0:
332
+ _ref = _args4.length > 2 && _args4[2] !== undefined ? _args4[2] : {}, _ref$keepCacheOnError = _ref.keepCacheOnError, keepCacheOnError = _ref$keepCacheOnError === void 0 ? false : _ref$keepCacheOnError;
333
+ url = jwksUrl(config.issuer);
334
+ existing = inFlight.get(url);
335
+ if (!existing) {
336
+ _context4.n = 1;
337
+ break;
338
+ }
339
+ return _context4.a(2, existing);
340
+ case 1:
214
341
  fetchImpl = (_config$fetchImpl = config.fetchImpl) !== null && _config$fetchImpl !== void 0 ? _config$fetchImpl : fetch;
215
- pending = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee() {
342
+ pending = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee3() {
216
343
  var res, body, keys;
217
- return _regenerator().w(function (_context) {
218
- while (1) switch (_context.p = _context.n) {
344
+ return _regenerator().w(function (_context3) {
345
+ while (1) switch (_context3.p = _context3.n) {
219
346
  case 0:
220
- _context.p = 0;
221
- _context.n = 1;
347
+ _context3.p = 0;
348
+ _context3.n = 1;
222
349
  return fetchImpl(url, {
223
350
  headers: {
224
351
  accept: "application/json"
225
352
  }
226
353
  });
227
354
  case 1:
228
- res = _context.v;
355
+ res = _context3.v;
229
356
  if (res.ok) {
230
- _context.n = 2;
357
+ _context3.n = 2;
231
358
  break;
232
359
  }
233
- return _context.a(2, {
360
+ return _context3.a(2, {
234
361
  status: "error",
235
362
  failedAtMs: nowMs
236
363
  });
237
364
  case 2:
238
- _context.n = 3;
365
+ _context3.n = 3;
239
366
  return res.json();
240
367
  case 3:
241
- body = _context.v;
368
+ body = _context3.v;
242
369
  keys = readKeys(body);
243
370
  if (!(keys === null)) {
244
- _context.n = 4;
371
+ _context3.n = 4;
245
372
  break;
246
373
  }
247
- return _context.a(2, {
374
+ return _context3.a(2, {
248
375
  status: "error",
249
376
  failedAtMs: nowMs
250
377
  });
251
378
  case 4:
252
- return _context.a(2, {
379
+ return _context3.a(2, {
253
380
  status: "keys",
254
381
  keys: keys,
255
382
  fetchedAtMs: nowMs
256
383
  });
257
384
  case 5:
258
- _context.p = 5;
259
- _context.v;
260
- return _context.a(2, {
385
+ _context3.p = 5;
386
+ _context3.v;
387
+ return _context3.a(2, {
261
388
  status: "error",
262
389
  failedAtMs: nowMs
263
390
  });
264
391
  }
265
- }, _callee, null, [[0, 5]]);
392
+ }, _callee3, null, [[0, 5]]);
266
393
  }))();
267
394
  inFlight.set(url, pending);
268
- _context2.p = 3;
269
- _context2.n = 4;
395
+ _context4.p = 2;
396
+ _context4.n = 3;
270
397
  return pending;
398
+ case 3:
399
+ entry = _context4.v;
400
+ cached = jwksCache.get(url);
401
+ if (!(entry.status === "error" && keepCacheOnError && (cached === null || cached === void 0 ? void 0 : cached.status) === "keys")) {
402
+ _context4.n = 4;
403
+ break;
404
+ }
405
+ return _context4.a(2, entry);
271
406
  case 4:
272
- entry = _context2.v;
273
407
  jwksCache.set(url, entry);
274
- return _context2.a(2, entry);
408
+ return _context4.a(2, entry);
275
409
  case 5:
276
- _context2.p = 5;
410
+ _context4.p = 5;
277
411
  inFlight["delete"](url);
278
- return _context2.f(5);
412
+ return _context4.f(5);
279
413
  case 6:
280
- return _context2.a(2);
414
+ return _context4.a(2);
281
415
  }
282
- }, _callee2, null, [[3,, 5, 6]]);
416
+ }, _callee4, null, [[2,, 5, 6]]);
283
417
  }));
284
- return _loadJwks.apply(this, arguments);
418
+ return _fetchJwks.apply(this, arguments);
285
419
  }
286
420
  function readKeys(body) {
287
421
  if (_typeof(body) !== "object" || body === null || !("keys" in body)) {
@@ -316,22 +450,22 @@ function readBearerToken(request) {
316
450
  var token = match === null || match === void 0 || (_match$ = match[1]) === null || _match$ === void 0 ? void 0 : _match$.trim();
317
451
  return token ? token : null;
318
452
  }
319
- function verifyValAccessToken(_x3, _x4) {
453
+ function verifyValAccessToken(_x7, _x8) {
320
454
  return _verifyValAccessToken.apply(this, arguments);
321
455
  }
322
456
  function _verifyValAccessToken() {
323
- _verifyValAccessToken = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee3(request, config) {
457
+ _verifyValAccessToken = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee5(request, config) {
324
458
  var _config$clockToleranc;
325
- var token, parts, _parts, encodedHeader, encodedPayload, encodedSignature, header, kid, nowMs, jwks, candidates, signature, signedData, verified, payload, tolerance, nowSeconds, subject, scopes;
326
- return _regenerator().w(function (_context3) {
327
- while (1) switch (_context3.n) {
459
+ var token, parts, _parts, encodedHeader, encodedPayload, encodedSignature, header, kid, nowMs, loaded, candidates, refetched, signature, signedData, verified, payload, tolerance, nowSeconds, subject, scopes;
460
+ return _regenerator().w(function (_context5) {
461
+ while (1) switch (_context5.n) {
328
462
  case 0:
329
463
  token = readBearerToken(request);
330
464
  if (!(token === null)) {
331
- _context3.n = 1;
465
+ _context5.n = 1;
332
466
  break;
333
467
  }
334
- return _context3.a(2, {
468
+ return _context5.a(2, {
335
469
  status: "refused",
336
470
  error: "invalid_request",
337
471
  description: "This Val MCP endpoint needs an access token. Authorize with the Val authorization server and present it as `Authorization: Bearer`."
@@ -339,125 +473,148 @@ function _verifyValAccessToken() {
339
473
  case 1:
340
474
  parts = token.split(".");
341
475
  if (!(parts.length !== 3)) {
342
- _context3.n = 2;
476
+ _context5.n = 2;
343
477
  break;
344
478
  }
345
- return _context3.a(2, invalidToken("The access token is not a JWS."));
479
+ return _context5.a(2, invalidToken("The access token is not a JWS."));
346
480
  case 2:
347
481
  _parts = _slicedToArray(parts, 3), encodedHeader = _parts[0], encodedPayload = _parts[1], encodedSignature = _parts[2];
348
482
  header = decodeJsonSegment(encodedHeader);
349
483
  if (!(header === null)) {
350
- _context3.n = 3;
484
+ _context5.n = 3;
351
485
  break;
352
486
  }
353
- return _context3.a(2, invalidToken("The access token's header could not be read."));
487
+ return _context5.a(2, invalidToken("The access token's header could not be read."));
354
488
  case 3:
355
489
  if (!(header.alg !== "ES256")) {
356
- _context3.n = 4;
490
+ _context5.n = 4;
357
491
  break;
358
492
  }
359
- return _context3.a(2, invalidToken("The access token is not signed with ES256, which is the only algorithm this server accepts."));
493
+ return _context5.a(2, invalidToken("The access token is not signed with ES256, which is the only algorithm this server accepts."));
360
494
  case 4:
361
495
  kid = typeof header.kid === "string" ? header.kid : null;
362
496
  nowMs = Date.now();
363
- _context3.n = 5;
497
+ _context5.n = 5;
364
498
  return loadJwks(config, nowMs);
365
499
  case 5:
366
- jwks = _context3.v;
367
- if (!(jwks.status === "error")) {
368
- _context3.n = 6;
500
+ loaded = _context5.v;
501
+ if (!(loaded.entry.status === "error")) {
502
+ _context5.n = 6;
369
503
  break;
370
504
  }
371
- return _context3.a(2, invalidToken("The access token could not be verified because the Val authorization server's keys could not be fetched. This may be temporary."));
505
+ return _context5.a(2, invalidToken("The access token could not be verified because the Val authorization server's keys could not be fetched. This may be temporary."));
372
506
  case 6:
373
- candidates = jwks.keys.filter(function (key) {
507
+ candidates = loaded.entry.keys.filter(function (key) {
374
508
  return isVerifyingP256Key(key, kid);
375
509
  });
376
- if (!(candidates.length === 0)) {
377
- _context3.n = 7;
510
+ if (!(candidates.length === 0 && kid !== null && loaded.fromCache)) {
511
+ _context5.n = 9;
378
512
  break;
379
513
  }
380
- return _context3.a(2, invalidToken("The access token was signed with a key the Val authorization server does not publish."));
514
+ _context5.n = 7;
515
+ return refetchForUnknownKid(config, nowMs);
381
516
  case 7:
517
+ refetched = _context5.v;
518
+ if (!(refetched !== null)) {
519
+ _context5.n = 9;
520
+ break;
521
+ }
522
+ if (!(refetched.status === "error")) {
523
+ _context5.n = 8;
524
+ break;
525
+ }
526
+ return _context5.a(2, invalidToken("The access token could not be verified because the Val authorization server's keys could not be fetched. This may be temporary."));
527
+ case 8:
528
+ candidates = refetched.keys.filter(function (key) {
529
+ return isVerifyingP256Key(key, kid);
530
+ });
531
+ case 9:
532
+ if (!(candidates.length === 0)) {
533
+ _context5.n = 10;
534
+ break;
535
+ }
536
+ return _context5.a(2, invalidToken("The access token was signed with a key the Val authorization server does not publish."));
537
+ case 10:
382
538
  signature = decodeBase64Url(encodedSignature);
383
539
  if (!(signature === null)) {
384
- _context3.n = 8;
540
+ _context5.n = 11;
385
541
  break;
386
542
  }
387
- return _context3.a(2, invalidToken("The access token's signature could not be read."));
388
- case 8:
543
+ return _context5.a(2, invalidToken("The access token's signature could not be read."));
544
+ case 11:
389
545
  signedData = Buffer.from("".concat(encodedHeader, ".").concat(encodedPayload), "ascii"); // Every published key is tried when the token names no `kid`, so a rotation
390
546
  // that has not yet propagated to clients still verifies. With a `kid` the
391
- // filter above leaves one.
547
+ // filter above leaves one — or, if this process had not yet seen that key,
548
+ // the one the refetch just found.
392
549
  verified = candidates.some(function (key) {
393
550
  return verifyWithJwk(key, signedData, signature);
394
551
  });
395
552
  if (verified) {
396
- _context3.n = 9;
553
+ _context5.n = 12;
397
554
  break;
398
555
  }
399
- return _context3.a(2, invalidToken("The access token's signature could not be verified."));
400
- case 9:
556
+ return _context5.a(2, invalidToken("The access token's signature could not be verified."));
557
+ case 12:
401
558
  // Only now: everything below reads the payload, and before this line it was
402
559
  // attacker input.
403
560
  payload = decodeJsonSegment(encodedPayload);
404
561
  if (!(payload === null)) {
405
- _context3.n = 10;
562
+ _context5.n = 13;
406
563
  break;
407
564
  }
408
- return _context3.a(2, invalidToken("The access token's payload could not be read."));
409
- case 10:
565
+ return _context5.a(2, invalidToken("The access token's payload could not be read."));
566
+ case 13:
410
567
  tolerance = (_config$clockToleranc = config.clockToleranceSeconds) !== null && _config$clockToleranc !== void 0 ? _config$clockToleranc : DEFAULT_CLOCK_TOLERANCE_SECONDS;
411
568
  nowSeconds = Math.floor(nowMs / 1000);
412
569
  if (!(typeof payload.exp !== "number")) {
413
- _context3.n = 11;
570
+ _context5.n = 14;
414
571
  break;
415
572
  }
416
- return _context3.a(2, invalidToken("The access token has no expiry."));
417
- case 11:
573
+ return _context5.a(2, invalidToken("The access token has no expiry."));
574
+ case 14:
418
575
  if (!(payload.exp + tolerance <= nowSeconds)) {
419
- _context3.n = 12;
576
+ _context5.n = 15;
420
577
  break;
421
578
  }
422
- return _context3.a(2, invalidToken("The access token has expired. Refresh it and try again."));
423
- case 12:
579
+ return _context5.a(2, invalidToken("The access token has expired. Refresh it and try again."));
580
+ case 15:
424
581
  if (!(typeof payload.nbf === "number" && payload.nbf - tolerance > nowSeconds)) {
425
- _context3.n = 13;
582
+ _context5.n = 16;
426
583
  break;
427
584
  }
428
- return _context3.a(2, invalidToken("The access token is not valid yet."));
429
- case 13:
585
+ return _context5.a(2, invalidToken("The access token is not valid yet."));
586
+ case 16:
430
587
  if (!(payload.iss !== config.issuer)) {
431
- _context3.n = 14;
588
+ _context5.n = 17;
432
589
  break;
433
590
  }
434
- return _context3.a(2, invalidToken("The access token was not issued by this server's authorization server (iss claim)."));
435
- case 14:
591
+ return _context5.a(2, invalidToken("The access token was not issued by this server's authorization server (iss claim)."));
592
+ case 17:
436
593
  if (audienceMatches(payload.aud, config.resource)) {
437
- _context3.n = 15;
594
+ _context5.n = 18;
438
595
  break;
439
596
  }
440
- return _context3.a(2, invalidToken("The access token is not valid for this server (aud claim)."));
441
- case 15:
597
+ return _context5.a(2, invalidToken("The access token is not valid for this server (aud claim)."));
598
+ case 18:
442
599
  subject = payload.sub;
443
600
  if (!(typeof subject !== "string" || subject.length === 0)) {
444
- _context3.n = 16;
601
+ _context5.n = 19;
445
602
  break;
446
603
  }
447
- return _context3.a(2, invalidToken("The access token has no subject."));
448
- case 16:
604
+ return _context5.a(2, invalidToken("The access token has no subject."));
605
+ case 19:
449
606
  scopes = readScopes(payload.scope);
450
607
  if (scopes.includes(VAL_SCOPE_READ)) {
451
- _context3.n = 17;
608
+ _context5.n = 20;
452
609
  break;
453
610
  }
454
- return _context3.a(2, {
611
+ return _context5.a(2, {
455
612
  status: "refused",
456
613
  error: "insufficient_scope",
457
614
  description: "The access token does not have the ".concat(VAL_SCOPE_READ, " scope, so it cannot read any content.")
458
615
  });
459
- case 17:
460
- return _context3.a(2, {
616
+ case 20:
617
+ return _context5.a(2, {
461
618
  status: "ok",
462
619
  auth: {
463
620
  type: "verified-profile",
@@ -466,7 +623,7 @@ function _verifyValAccessToken() {
466
623
  }
467
624
  });
468
625
  }
469
- }, _callee3);
626
+ }, _callee5);
470
627
  }));
471
628
  return _verifyValAccessToken.apply(this, arguments);
472
629
  }