@idpartners/client-attestation-sdk 1.0.1 → 1.2.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.
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * import { ClientAttestation } from "@idpartners/client-attestation-sdk";
13
13
  *
14
- * const ca = new ClientAttestation("pf.example.com");
14
+ * const ca = new ClientAttestation("pf.example.com", { evidenceAudience: "https://attester.example.com/evidence" });
15
15
  * const token = await ca.token(); // cached until it nears expiry
16
16
  *
17
17
  * // the fetch wrapper attaches the token (and a DPoP proof for a DPoP-bound token):
@@ -20,22 +20,38 @@
20
20
  * const headers = await ca.requestHeaders("GET", "https://api.example.com/things");
21
21
  */
22
22
  import { base64url } from "jose";
23
- import { DpopProofBuilder } from "./builders.js";
23
+ import { checkHtu, DpopProofBuilder, htuParts, normalizeHtu } from "./builders.js";
24
24
  import { ClientAttestationCredential, DPOP_HEADER } from "./credential.js";
25
25
  import { ClientAttestationError, TransportError } from "./errors.js";
26
26
  import { autoDetectEvidence } from "./evidence.js";
27
27
  import { MintRequest, PingFederateProfile, metadataDocument, } from "./attester.js";
28
- import { jsonOk, request } from "./http.js";
28
+ import { checkSecure, jsonOk, request, snippet } from "./http.js";
29
29
  import { emit, failureReason, hostOf, NOOP_LOGGER, urlParts } from "./logging.js";
30
- import { utf8Decode } from "./runtime.js";
30
+ import { monotonicMs, utf8Decode } from "./runtime.js";
31
31
  import { SigningKeyPair } from "./signing-key-pair.js";
32
32
  export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
33
33
  export const POP_HEADER = "OAuth-Client-Attestation-PoP";
34
34
  /** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
35
35
  export const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
36
+ const POP_JWT = "attestation_pop_jwt";
37
+ const DPOP_COMBINED = "dpop_combined";
38
+ const POP_METHODS = [POP_JWT, DPOP_COMBINED];
39
+ const PIN_REQUIRED = "evidenceAudience is required: set it to the audience your attester expects the workload's " +
40
+ "identity token for, so the attester's discovery document can't choose it";
36
41
  export { ClientAttestationError, TransportError } from "./errors.js";
37
42
  const USE_DPOP_NONCE = "use_dpop_nonce";
38
43
  const DPOP_NONCE_HEADER = "DPoP-Nonce";
44
+ /** At most this much of a 400 body is read to look for `use_dpop_nonce`; a longer body is not a challenge. */
45
+ const MAX_NONCE_CHALLENGE_BODY = 64 * 1024;
46
+ /** RFC 9449 nonces are short opaque strings: a longer `DPoP-Nonce` is ignored, as if there were none. */
47
+ const MAX_DPOP_NONCE_LENGTH = 1024;
48
+ /**
49
+ * At most this many origins' nonces are remembered; a new origin over the cap replaces the one stored
50
+ * longest ago (it costs that origin one challenge).
51
+ */
52
+ const MAX_DPOP_NONCE_ORIGINS = 256;
53
+ /** A token is cached for at most this long (seconds), whatever `expires_in` the token endpoint gives. */
54
+ const MAX_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60;
39
55
  /** What the https check calls the URL a profile's call goes to, by step. */
40
56
  const STEP_LABELS = {
41
57
  discover: "attesterUrl",
@@ -46,7 +62,7 @@ const STEP_LABELS = {
46
62
  * Gets and caches an access token using client attestation.
47
63
  *
48
64
  * `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
49
- * discovery) or a custom `opts.profile`.
65
+ * discovery) or a custom `opts.profile`. A client that discovers its attester needs `opts.evidenceAudience`.
50
66
  *
51
67
  * @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
52
68
  * `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
@@ -70,8 +86,10 @@ export class ClientAttestation {
70
86
  /** The token, its type and its bound key: always replaced together, never one without the others. */
71
87
  #state = null;
72
88
  #metadata;
89
+ #staticMetadata;
90
+ #evidenceAudience;
73
91
  #refreshing = null;
74
- /** The last `DPoP-Nonce` each origin returned. */
92
+ /** The last `DPoP-Nonce` each origin returned, oldest first. */
75
93
  #nonces = new Map();
76
94
  constructor(attesterUrl, opts = {}) {
77
95
  if (typeof attesterUrl === "object" && attesterUrl !== null) {
@@ -81,14 +99,19 @@ export class ClientAttestation {
81
99
  const url = attesterUrl ?? opts.attesterUrl;
82
100
  this.#profile = opts.profile ?? new PingFederateProfile();
83
101
  this.#metadata = opts.metadata ?? null;
102
+ this.#staticMetadata = this.#metadata !== null;
103
+ this.#evidenceAudience = opts.evidenceAudience;
84
104
  this.#evidence = opts.evidence;
85
105
  this.#authorizationDetails = opts.authorizationDetails;
86
106
  this.#operator = opts.operator && Object.keys(opts.operator).length > 0 ? opts.operator : undefined;
87
107
  this.#challengeAtToken = opts.challengeAtToken ?? true;
88
108
  this.#algorithm = opts.algorithm ?? "ES256";
89
109
  this.#skew = opts.expirySkewSeconds ?? DEFAULT_EXPIRY_SKEW_SECONDS;
110
+ if (!Number.isFinite(this.#skew) || this.#skew < 0) {
111
+ throw new RangeError(`expirySkewSeconds must be a finite number, not negative: ${this.#skew}`);
112
+ }
90
113
  this.#timeoutMs = opts.timeoutMs ?? 20_000;
91
- this.#popMethod = opts.popMethod ?? "attestation_pop_jwt";
114
+ this.#popMethod = checkPopMethod(opts.popMethod);
92
115
  this.#requireChallenge = opts.requireChallenge ?? true;
93
116
  this.#allowInsecure = opts.allowInsecure ?? false;
94
117
  this.#logger = opts.logger ?? NOOP_LOGGER;
@@ -121,16 +144,54 @@ export class ClientAttestation {
121
144
  * - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
122
145
  * token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
123
146
  * url's origin returned, if any.
147
+ *
148
+ * `url` must be https, like every other URL the client sends a credential to (loopback hosts and
149
+ * `allowInsecure` excepted).
150
+ *
151
+ * Pass the exact URL your HTTP client sends: the proof is only valid for the `htu` it names. A url with
152
+ * a backslash, ASCII whitespace or a control character before its query or fragment is refused with a
153
+ * {@link ClientAttestationError}, because URL parsers disagree on where it goes. For a DPoP-bound token,
154
+ * so is a url that is not an absolute http(s) URL with a valid ASCII host, port and path; with
155
+ * `popMethod: "dpop_combined"` that is checked before the token is fetched. The path is signed as
156
+ * written, so pass `new URL(u).href` (what `fetch` sends) rather than a hand-written string: `fetch` may
157
+ * escape characters (a `{`, say) that the string leaves as they are. {@link fetch} does this for you.
124
158
  */
125
159
  async requestHeaders(method, url, opts = {}) {
160
+ return this.#requestHeaders(method, url, opts);
161
+ }
162
+ /**
163
+ * {@link requestHeaders}, with the proof carrying `nonce` when given (the nonce of the challenge being
164
+ * answered) instead of the origin's remembered one.
165
+ */
166
+ async #requestHeaders(method, url, opts, nonce) {
167
+ this.#checkSecure(url, "resource URL");
168
+ // A client configured for DPoP checks the whole htu before fetching the token, so a URL with no htu
169
+ // never costs a mint and token request on a cold cache. Otherwise only what parsers disagree on is
170
+ // refused up front: a Bearer token carries no htu.
171
+ try {
172
+ if (this.#popMethod === DPOP_COMBINED)
173
+ normalizeHtu(url);
174
+ else
175
+ checkHtu(url);
176
+ }
177
+ catch (err) {
178
+ throw new ClientAttestationError(err.message);
179
+ }
126
180
  const state = await this.#current(opts);
127
181
  const headers = { Authorization: `${scheme(state)} ${state.accessToken}` };
128
182
  if (isDpop(state)) {
183
+ // Also for a DPoP token from a client not configured for DPoP (the AS chose the token type).
184
+ try {
185
+ normalizeHtu(url);
186
+ }
187
+ catch (err) {
188
+ throw new ClientAttestationError(err.message);
189
+ }
129
190
  headers[DPOP_HEADER] = await new DpopProofBuilder(state.key)
130
191
  .method(method)
131
192
  .uri(url)
132
193
  .accessToken(state.accessToken)
133
- .nonce(this.#nonces.get(originOf(url)) ?? null)
194
+ .nonce(nonce ?? this.#nonces.get(originOf(url)) ?? null)
134
195
  .build();
135
196
  }
136
197
  return headers;
@@ -144,14 +205,17 @@ export class ClientAttestation {
144
205
  */
145
206
  async fetch(input, init = {}) {
146
207
  const isRequest = typeof Request !== "undefined" && input instanceof Request;
147
- const url = isRequest ? input.url : String(input);
208
+ // The proof names the URL as fetch parses it (WHATWG), which is where the request goes.
209
+ const url = isRequest ? input.url : parsedHref(String(input));
148
210
  const method = (init.method ?? (isRequest ? input.method : "GET")).toUpperCase();
149
211
  // A Request's own body is cloned so the retry can send it again.
150
212
  const nextInput = () => (isRequest ? input.clone() : input);
151
213
  let dpopBound = false;
152
- const send = async () => {
214
+ const send = async (nonce) => {
153
215
  const headers = new Headers(init.headers ?? (isRequest ? input.headers : undefined));
154
- const auth = await this.requestHeaders(method, url);
216
+ const auth = nonce === undefined
217
+ ? await this.requestHeaders(method, url)
218
+ : await this.#requestHeaders(method, url, {}, nonce);
155
219
  dpopBound = DPOP_HEADER in auth;
156
220
  for (const [name, value] of Object.entries(auth))
157
221
  headers.set(name, value);
@@ -160,14 +224,24 @@ export class ClientAttestation {
160
224
  const first = await send();
161
225
  if (!dpopBound)
162
226
  return first;
163
- const nonce = first.headers.get(DPOP_NONCE_HEADER);
227
+ const nonce = usableNonce(first.headers.get(DPOP_NONCE_HEADER));
164
228
  if (nonce)
165
- this.#nonces.set(originOf(url), nonce);
166
- if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
229
+ this.#rememberNonce(url, nonce);
230
+ if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first, this.#timeoutMs)))
167
231
  return first;
168
232
  emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(url), status: first.status, at: "resource" });
169
233
  await first.body?.cancel().catch(() => undefined);
170
- return send();
234
+ // Answer this challenge with its own nonce, not one a concurrent response stored since.
235
+ return send(nonce);
236
+ }
237
+ /** Store `nonce` as the origin's latest, forgetting the origin stored longest ago over the cap. */
238
+ #rememberNonce(url, nonce) {
239
+ const key = originOf(url);
240
+ this.#nonces.delete(key);
241
+ if (this.#nonces.size >= MAX_DPOP_NONCE_ORIGINS) {
242
+ this.#nonces.delete(this.#nonces.keys().next().value);
243
+ }
244
+ this.#nonces.set(key, nonce);
171
245
  }
172
246
  /**
173
247
  * The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
@@ -201,9 +275,10 @@ export class ClientAttestation {
201
275
  /** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
202
276
  async #current(opts) {
203
277
  const state = this.#state;
204
- if (!opts.forceRefresh && state && Date.now() < state.expiresAt) {
278
+ const now = monotonicMs();
279
+ if (!opts.forceRefresh && state && now < state.expiresAt) {
205
280
  emit(this.#logger, "debug", "token.cache_hit", {
206
- expires_in: Math.max(0, Math.round((state.expiresAt - Date.now()) / 1000)),
281
+ expires_in: Math.max(0, Math.round((state.expiresAt - now) / 1000)),
207
282
  });
208
283
  return state;
209
284
  }
@@ -219,22 +294,33 @@ export class ClientAttestation {
219
294
  }
220
295
  async #refresh() {
221
296
  const { response, key } = await this.#runFlow();
222
- const expiresIn = response.expires_in;
297
+ const lifetime = tokenLifetime(response.expires_in);
223
298
  const state = {
224
299
  accessToken: response.access_token,
225
300
  tokenType: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
226
301
  key,
227
- expiresAt: expiresIn ? Date.now() + (Number(expiresIn) - this.#skew) * 1000 : 0,
302
+ expiresAt: lifetime !== undefined ? monotonicMs() + (lifetime - this.#skew) * 1000 : 0,
228
303
  };
229
304
  this.#state = state;
230
305
  return state;
231
306
  }
232
307
  async #runFlow() {
308
+ // The constructor refused anything else. A value that still isn't one of the two fails here, before
309
+ // any request, rather than falling back to a PoP JWT.
310
+ if (!POP_METHODS.includes(this.#popMethod)) {
311
+ throw new ClientAttestationError(`unsupported popMethod ${describe(this.#popMethod)}`);
312
+ }
313
+ if (!this.#evidenceAudience && !this.#staticMetadata)
314
+ throw new ClientAttestationError(PIN_REQUIRED);
233
315
  const meta = await this.attesterMetadata();
234
316
  const audience = meta.evidenceAudience;
235
317
  if (!audience) {
236
318
  throw new ClientAttestationError("attester discovery document has no evidence_audience");
237
319
  }
320
+ if (this.#evidenceAudience && audience !== this.#evidenceAudience) {
321
+ throw new ClientAttestationError(`the attester asks for evidence for ${JSON.stringify(audience)}, but evidenceAudience is ` +
322
+ JSON.stringify(this.#evidenceAudience));
323
+ }
238
324
  const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
239
325
  const svid = await evidenceSource.fetch(audience);
240
326
  emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
@@ -269,26 +355,27 @@ export class ClientAttestation {
269
355
  const tokenChallenge = this.#challengeAtToken ? challenge : undefined;
270
356
  const credential = new ClientAttestationCredential(attestation, instanceKey);
271
357
  const obtained = (response) => {
272
- const expiresIn = Number(response.expires_in);
358
+ const lifetime = tokenLifetime(response.expires_in);
273
359
  emit(this.#logger, "info", "token.obtained", {
274
360
  host: hostOf(tokenEndpoint),
275
361
  client_id: clientId,
276
362
  token_type: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
277
- expires_in: response.expires_in != null && Number.isFinite(expiresIn) ? expiresIn : undefined,
363
+ expires_in: lifetime !== undefined ? Math.trunc(lifetime) : undefined,
278
364
  pop_method: this.#popMethod,
279
365
  });
280
366
  return { response, key: instanceKey };
281
367
  };
282
- if (this.#popMethod !== "dpop_combined") {
368
+ if (this.#popMethod === POP_JWT) {
283
369
  const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
284
370
  return obtained(this.#tokenResponse(tokenEndpoint, await this.#postToken(tokenEndpoint, headers)));
285
371
  }
286
- // DPoP combined: the attester challenge rides as the DPoP nonce. If the AS demands its own nonce
287
- // (400 use_dpop_nonce + DPoP-Nonce), retry once with that — the server's demand wins.
372
+ // DPoP combined, the only other value (checked at the top of the flow): the attester challenge rides
373
+ // as the DPoP nonce. If the AS demands its own nonce (400 use_dpop_nonce + DPoP-Nonce), retry once
374
+ // with that — the server's demand wins.
288
375
  let res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, tokenChallenge ?? null));
289
- const serverNonce = res.headers.get(DPOP_NONCE_HEADER);
376
+ const serverNonce = usableNonce(res.headers.get(DPOP_NONCE_HEADER));
290
377
  if (serverNonce)
291
- this.#nonces.set(originOf(tokenEndpoint), serverNonce);
378
+ this.#rememberNonce(tokenEndpoint, serverNonce);
292
379
  if (res.status === 400 && serverNonce && jsonError(res.body) === USE_DPOP_NONCE) {
293
380
  emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
294
381
  res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
@@ -362,18 +449,7 @@ export class ClientAttestation {
362
449
  }
363
450
  /** Refuse a non-https URL unless it is loopback or the caller passed `allowInsecure`. */
364
451
  #checkSecure(url, what) {
365
- if (this.#allowInsecure)
366
- return;
367
- let parsed;
368
- try {
369
- parsed = new URL(url);
370
- }
371
- catch {
372
- throw new ClientAttestationError(`${what} is not a valid URL: ${url}`);
373
- }
374
- if (parsed.protocol === "https:" || LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
375
- return;
376
- throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
452
+ checkSecure(url, what, this.#allowInsecure);
377
453
  }
378
454
  async #getJson(step, method, url, jsonBody) {
379
455
  const headers = { Accept: "application/json" };
@@ -406,30 +482,63 @@ export class ClientAttestation {
406
482
  }
407
483
  }
408
484
  }
409
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
410
485
  function tokenResponse(res) {
411
486
  if (!res.ok) {
412
487
  throw new ClientAttestationError(`token endpoint returned HTTP ${res.status}`, {
413
488
  status: res.status,
414
- body: res.body,
489
+ body: snippet(res.body),
415
490
  });
416
491
  }
417
492
  const parsed = jsonOk(res, { allowEmpty: true });
418
493
  if (!parsed.access_token) {
419
- throw new ClientAttestationError("token endpoint returned no access_token", { body: res.body });
494
+ // No body: a 2xx token response may hold the token under another name.
495
+ throw new ClientAttestationError("token endpoint returned no access_token");
420
496
  }
421
497
  return parsed;
422
498
  }
499
+ /**
500
+ * How long to cache a token, in seconds: `expires_in` as a JSON number, or a string of decimal digits,
501
+ * capped at {@link MAX_TOKEN_LIFETIME_SECONDS}. Anything else - missing, null, not a number, not finite,
502
+ * zero or negative - is undefined, and the token is not cached, as with no `expires_in` at all.
503
+ */
504
+ function tokenLifetime(expiresIn) {
505
+ let seconds;
506
+ if (typeof expiresIn === "number")
507
+ seconds = expiresIn;
508
+ else if (typeof expiresIn === "string" && /^[0-9]+(\.[0-9]+)?$/.test(expiresIn))
509
+ seconds = Number(expiresIn);
510
+ else
511
+ return undefined;
512
+ if (!Number.isFinite(seconds) || seconds <= 0)
513
+ return undefined;
514
+ return Math.min(seconds, MAX_TOKEN_LIFETIME_SECONDS);
515
+ }
423
516
  function isDpop(state) {
424
517
  return state.tokenType.toLowerCase() === "dpop";
425
518
  }
426
519
  function scheme(state) {
427
520
  return isDpop(state) ? "DPoP" : "Bearer";
428
521
  }
429
- /** `scheme://host[:port]` of a URL, the key DPoP nonces are remembered under. */
522
+ /** A `DPoP-Nonce` value, or null when there is none or it is longer than {@link MAX_DPOP_NONCE_LENGTH}. */
523
+ function usableNonce(nonce) {
524
+ return nonce && nonce.length <= MAX_DPOP_NONCE_LENGTH ? nonce : null;
525
+ }
526
+ /** `url` as WHATWG URL parsing (and so fetch) serialises it, or as given when it does not parse. */
527
+ function parsedHref(url) {
528
+ try {
529
+ return new URL(url).href;
530
+ }
531
+ catch {
532
+ return url;
533
+ }
534
+ }
535
+ /**
536
+ * `scheme://host[:port]` of a URL as its `htu` names it ({@link htuParts}), the key DPoP nonces are
537
+ * remembered under. A URL that rule refuses is its own key.
538
+ */
430
539
  function originOf(url) {
431
540
  try {
432
- return new URL(url).origin;
541
+ return htuParts(url)[0];
433
542
  }
434
543
  catch {
435
544
  return url;
@@ -453,18 +562,64 @@ function replayable(body) {
453
562
  }
454
563
  /**
455
564
  * RFC 9449 section 9: a resource asks for a nonce with 401 + `WWW-Authenticate: DPoP error="use_dpop_nonce"`;
456
- * section 8 (and some resources) use a 400 JSON `{"error":"use_dpop_nonce"}`.
565
+ * section 8 (and some resources) use a 400 JSON `{"error":"use_dpop_nonce"}`. The 400 body is read from a
566
+ * clone, at most {@link MAX_NONCE_CHALLENGE_BODY} bytes and for at most `timeoutMs`, so the caller still
567
+ * gets the whole body.
457
568
  */
458
- async function isNonceChallenge(res) {
569
+ async function isNonceChallenge(res, timeoutMs) {
459
570
  if (res.status === 401) {
460
571
  const challenge = res.headers.get("WWW-Authenticate") ?? "";
461
572
  return /(^|,)\s*DPoP\b/i.test(challenge) && /error\s*=\s*"?use_dpop_nonce"?/i.test(challenge);
462
573
  }
463
574
  if (res.status === 400) {
464
- return jsonError(await res.clone().text()) === USE_DPOP_NONCE;
575
+ const body = await peekBody(res, MAX_NONCE_CHALLENGE_BODY, timeoutMs);
576
+ return body !== undefined && jsonError(body) === USE_DPOP_NONCE;
465
577
  }
466
578
  return false;
467
579
  }
580
+ /**
581
+ * The text of `res`'s body read from a clone, or undefined when it is longer than `maxBytes`, fails, or is
582
+ * not complete within `timeoutMs`. The clone is cancelled once read, so no more than about `maxBytes` is
583
+ * buffered for the caller's copy.
584
+ */
585
+ async function peekBody(res, maxBytes, timeoutMs) {
586
+ const reader = res.clone().body?.getReader();
587
+ if (!reader)
588
+ return "";
589
+ let timer;
590
+ const expired = new Promise((resolve) => {
591
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
592
+ });
593
+ const chunks = [];
594
+ let total = 0;
595
+ try {
596
+ for (;;) {
597
+ const next = await Promise.race([reader.read(), expired]);
598
+ if (next === undefined)
599
+ return undefined;
600
+ if (next.done)
601
+ break;
602
+ total += next.value.byteLength;
603
+ if (total > maxBytes)
604
+ return undefined;
605
+ chunks.push(next.value);
606
+ }
607
+ }
608
+ catch {
609
+ return undefined;
610
+ }
611
+ finally {
612
+ clearTimeout(timer);
613
+ reader.cancel().catch(() => undefined);
614
+ }
615
+ const all = new Uint8Array(total);
616
+ let offset = 0;
617
+ for (const chunk of chunks) {
618
+ all.set(chunk, offset);
619
+ offset += chunk.byteLength;
620
+ }
621
+ return utf8Decode(all);
622
+ }
468
623
  /**
469
624
  * Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
470
625
  * bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
@@ -483,6 +638,37 @@ function sortKeys(value) {
483
638
  }
484
639
  return value;
485
640
  }
641
+ /**
642
+ * The PoP method to use. `undefined`, `null` or `""` (unset) is the default; any other value must be
643
+ * exactly one of the two the draft names (case-sensitive), so a misspelt value can't quietly send a PoP
644
+ * JWT (vectors/pop-method.json).
645
+ */
646
+ function checkPopMethod(popMethod) {
647
+ if (popMethod === undefined || popMethod === null || popMethod === "")
648
+ return POP_JWT;
649
+ const known = POP_METHODS.find((name) => name === popMethod);
650
+ if (known === undefined) {
651
+ throw new RangeError(`popMethod must be "${POP_JWT}" or "${DPOP_COMBINED}", got ${describe(popMethod)}`);
652
+ }
653
+ return known;
654
+ }
655
+ /** A value for an error message: JSON where that works, never a throw (BigInt, a cycle, a symbol). */
656
+ function describe(value) {
657
+ try {
658
+ const json = JSON.stringify(value);
659
+ if (json !== undefined)
660
+ return json;
661
+ }
662
+ catch {
663
+ // fall through
664
+ }
665
+ try {
666
+ return typeof value === "bigint" ? `${value}n` : String(value);
667
+ }
668
+ catch {
669
+ return Object.prototype.toString.call(value);
670
+ }
671
+ }
486
672
  /** @internal */
487
673
  export function normalizeBaseUrl(attesterUrl) {
488
674
  let host = (attesterUrl ?? "").trim().replace(/\/+$/, "");
@@ -15,7 +15,11 @@ export interface IntrospectionConfig {
15
15
  endpoint: string;
16
16
  clientId: string;
17
17
  clientSecret: string;
18
- /** Defaults to `"client_secret_basic"`. */
18
+ /**
19
+ * Defaults to `"client_secret_basic"`, also when `null` or `""` (so an unset environment variable can be
20
+ * passed straight in). The validator refuses any other value than the two {@link IntrospectionAuthMethod}s,
21
+ * so a typo can't move the secret into the form body.
22
+ */
19
23
  authMethod?: IntrospectionAuthMethod;
20
24
  }
21
25
  /**
@@ -31,6 +35,10 @@ export interface IntrospectionConfig {
31
35
  * An explicitly empty `acceptedAlgorithms` or `acceptedTokenTypes` accepts nothing, and `leewaySeconds: 0`
32
36
  * allows no clock skew: only an omitted field takes the default. `none` and the `HS*` algorithms are
33
37
  * refused even when listed (see validation/SEMANTICS.md).
38
+ *
39
+ * `jwksUri` and the introspection `endpoint` must use https unless their host is loopback (`localhost`,
40
+ * `127.0.0.1`, `::1`) or `allowInsecure` is set: over plain http anyone on the path could serve their own
41
+ * keys, or read the resource server's client secret.
34
42
  */
35
43
  export interface ValidatorConfig {
36
44
  issuer: string;
@@ -45,6 +53,8 @@ export interface ValidatorConfig {
45
53
  introspection?: IntrospectionConfig;
46
54
  /** Defaults to {@link DEFAULT_TOKEN_TYPES}; `null` disables the `typ` check. */
47
55
  acceptedTokenTypes?: string[] | null;
56
+ /** Allow a plain-http `jwksUri` and introspection `endpoint` on non-loopback hosts. Defaults to `false`. */
57
+ allowInsecure?: boolean;
48
58
  }
49
59
  /** A {@link ValidatorConfig} with every optional field defaulted. */
50
60
  export interface ResolvedConfig {
@@ -33,8 +33,37 @@ export function normaliseType(value) {
33
33
  const lower = value.toLowerCase();
34
34
  return lower.startsWith("application/") ? lower.slice("application/".length) : lower;
35
35
  }
36
+ const AUTH_METHODS = ["client_secret_basic", "client_secret_post"];
37
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
38
+ /** Refuse a non-https URL unless it is loopback or `allowInsecure` is set: the rule the client applies. */
39
+ function checkSecure(url, what, allowInsecure) {
40
+ if (allowInsecure)
41
+ return;
42
+ let parsed;
43
+ try {
44
+ parsed = new URL(url);
45
+ }
46
+ catch {
47
+ throw new Error(`${what} is not a valid URL: ${url}`);
48
+ }
49
+ if (parsed.protocol === "https:" || LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
50
+ return;
51
+ throw new Error(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
52
+ }
53
+ /**
54
+ * A list option as a copy, refusing anything but an array of strings. JavaScript callers aren't held to
55
+ * the types, and a bare string would otherwise spread into its characters.
56
+ */
57
+ function stringList(value, what) {
58
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
59
+ throw new Error(`${what} must be an array of strings`);
60
+ }
61
+ return [...value];
62
+ }
36
63
  /**
37
- * Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`).
64
+ * Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`). The result
65
+ * shares nothing mutable with `config`: a caller reusing or changing its config object afterwards (its
66
+ * arrays, its JWKS, its introspection settings) can't change a live validator.
38
67
  *
39
68
  * @internal
40
69
  */
@@ -42,20 +71,60 @@ export function resolveConfig(config) {
42
71
  if (!config.issuer) {
43
72
  throw new Error("issuer is required");
44
73
  }
45
- const audiences = typeof config.audiences === "string" ? [config.audiences] : [...(config.audiences ?? [])];
74
+ const audiences = typeof config.audiences === "string" ? [config.audiences] : stringList(config.audiences ?? [], "audiences");
46
75
  if (audiences.length === 0) {
47
76
  throw new Error("at least one audience is required");
48
77
  }
78
+ // NaN would make every exp/nbf/iat comparison false (no time check at all), and "60" would concatenate
79
+ const leeway = config.leewaySeconds ?? 60;
80
+ if (typeof leeway !== "number" || !Number.isSafeInteger(leeway) || leeway < 0) {
81
+ throw new Error("leewaySeconds must be a non-negative integer");
82
+ }
83
+ const allowInsecure = config.allowInsecure ?? false;
84
+ if (typeof allowInsecure !== "boolean") {
85
+ throw new Error("allowInsecure must be a boolean");
86
+ }
87
+ if (config.jwksUri) {
88
+ checkSecure(config.jwksUri, "jwksUri", allowInsecure);
89
+ }
90
+ if (config.introspection?.endpoint) {
91
+ checkSecure(config.introspection.endpoint, "introspection endpoint", allowInsecure);
92
+ }
93
+ // JavaScript callers aren't held to the union type: anything else used to be taken as client_secret_post.
94
+ // Unset (undefined, null or "") is the default, client_secret_basic.
95
+ const authMethod = config.introspection?.authMethod;
96
+ const unset = authMethod == null || authMethod === "";
97
+ if (config.introspection != null && !unset && !AUTH_METHODS.includes(authMethod)) {
98
+ // never echo the value: a secret passed in the wrong field would end up in the message
99
+ throw new Error(`introspection authMethod must be "client_secret_basic" or "client_secret_post", got ${describe(authMethod)}`);
100
+ }
101
+ if (config.introspection != null) {
102
+ for (const field of ["clientId", "clientSecret"]) {
103
+ const value = config.introspection[field];
104
+ if (typeof value !== "string" || value === "") {
105
+ throw new Error(`introspection ${field} must be a non-empty string, got ${value === "" ? "an empty string" : describe(value)}`);
106
+ }
107
+ }
108
+ }
49
109
  const types = config.acceptedTokenTypes === undefined ? DEFAULT_TOKEN_TYPES : config.acceptedTokenTypes;
50
110
  return {
51
111
  issuer: config.issuer,
52
112
  audiences,
53
- jwks: config.jwks,
113
+ // the JWKS provider keeps each JWK and imports it again for each algorithm a token names
114
+ jwks: config.jwks == null ? undefined : structuredClone(config.jwks),
54
115
  jwksUri: config.jwksUri,
55
- requiredScopes: [...(config.requiredScopes ?? [])],
56
- acceptedAlgorithms: [...(config.acceptedAlgorithms ?? DEFAULT_ALGORITHMS)],
57
- leewaySeconds: config.leewaySeconds ?? 60,
58
- introspection: config.introspection,
59
- acceptedTokenTypes: types === null ? null : types.map(normaliseType),
116
+ requiredScopes: stringList(config.requiredScopes ?? [], "requiredScopes"),
117
+ acceptedAlgorithms: stringList(config.acceptedAlgorithms ?? DEFAULT_ALGORITHMS, "acceptedAlgorithms"),
118
+ leewaySeconds: leeway,
119
+ introspection: config.introspection == null
120
+ ? undefined
121
+ : { ...config.introspection, authMethod: config.introspection.authMethod || "client_secret_basic" },
122
+ acceptedTokenTypes: types === null ? null : stringList(types, "acceptedTokenTypes").map(normaliseType),
60
123
  };
61
124
  }
125
+ /** A value's type and size, never its content (it may be a secret). */
126
+ function describe(value) {
127
+ if (typeof value === "string")
128
+ return `a string of ${[...value].length} characters`;
129
+ return value === null ? "null" : `a ${typeof value}`;
130
+ }
@@ -16,4 +16,4 @@ export type { ValidationError } from "./errors.js";
16
16
  export * as errors from "./errors.js";
17
17
  export { ProtectedResource, bearerToken } from "./resource.js";
18
18
  export type { ProtectedResourceMetadata, AuthDecision } from "./resource.js";
19
- export declare const VALIDATOR_VERSION = "1.0.1";
19
+ export declare const VALIDATOR_VERSION = "1.2.0";
@@ -9,4 +9,4 @@ export { AccessTokenValidator } from "./validator.js";
9
9
  export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
10
10
  export * as errors from "./errors.js";
11
11
  export { ProtectedResource, bearerToken } from "./resource.js";
12
- export const VALIDATOR_VERSION = "1.0.1";
12
+ export const VALIDATOR_VERSION = "1.2.0";