@idpartners/client-attestation-sdk 1.0.1 → 1.4.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,33 +20,53 @@
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";
24
- import { ClientAttestationCredential, DPOP_HEADER } from "./credential.js";
23
+ import { checkHtu, DpopProofBuilder, htuParts, normalizeHtu } from "./builders.js";
24
+ import { CHALLENGE_HEADER, 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
+ import { isDpopNonceChallenge } from "./www-authenticate.js";
32
33
  export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
33
34
  export const POP_HEADER = "OAuth-Client-Attestation-PoP";
34
35
  /** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
35
36
  export const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
37
+ const POP_JWT = "attestation_pop_jwt";
38
+ const DPOP_COMBINED = "dpop_combined";
39
+ const POP_METHODS = [POP_JWT, DPOP_COMBINED];
40
+ const PIN_REQUIRED = "evidenceAudience is required: set it to the audience your attester expects the workload's " +
41
+ "identity token for, so the attester's discovery document can't choose it";
36
42
  export { ClientAttestationError, TransportError } from "./errors.js";
37
43
  const USE_DPOP_NONCE = "use_dpop_nonce";
38
44
  const DPOP_NONCE_HEADER = "DPoP-Nonce";
45
+ /** draft -11 section 6.1: the refusal a server that uses challenges answers a PoP-JWT request without one. */
46
+ const USE_ATTESTATION_CHALLENGE = "use_attestation_challenge";
47
+ /** At most this much of a 400 body is read to look for `use_dpop_nonce`; a longer body is not a challenge. */
48
+ const MAX_NONCE_CHALLENGE_BODY = 64 * 1024;
49
+ /** RFC 9449 nonces are short opaque strings: a longer `DPoP-Nonce` is ignored, as if there were none. */
50
+ const MAX_DPOP_NONCE_LENGTH = 1024;
51
+ /**
52
+ * At most this many origins' nonces are remembered; a new origin over the cap replaces the one stored
53
+ * longest ago (it costs that origin one challenge).
54
+ */
55
+ const MAX_DPOP_NONCE_ORIGINS = 256;
56
+ /** A token is cached for at most this long (seconds), whatever `expires_in` the token endpoint gives. */
57
+ const MAX_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60;
39
58
  /** What the https check calls the URL a profile's call goes to, by step. */
40
59
  const STEP_LABELS = {
41
60
  discover: "attesterUrl",
42
61
  mint: "attestation_endpoint",
43
62
  challenge: "challenge_endpoint",
63
+ token_challenge: "token_challenge_endpoint",
44
64
  };
45
65
  /**
46
66
  * Gets and caches an access token using client attestation.
47
67
  *
48
68
  * `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
49
- * discovery) or a custom `opts.profile`.
69
+ * discovery) or a custom `opts.profile`. A client that discovers its attester needs `opts.evidenceAudience`.
50
70
  *
51
71
  * @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
52
72
  * `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
@@ -70,8 +90,10 @@ export class ClientAttestation {
70
90
  /** The token, its type and its bound key: always replaced together, never one without the others. */
71
91
  #state = null;
72
92
  #metadata;
93
+ #staticMetadata;
94
+ #evidenceAudience;
73
95
  #refreshing = null;
74
- /** The last `DPoP-Nonce` each origin returned. */
96
+ /** The last `DPoP-Nonce` each origin returned, oldest first. */
75
97
  #nonces = new Map();
76
98
  constructor(attesterUrl, opts = {}) {
77
99
  if (typeof attesterUrl === "object" && attesterUrl !== null) {
@@ -81,14 +103,19 @@ export class ClientAttestation {
81
103
  const url = attesterUrl ?? opts.attesterUrl;
82
104
  this.#profile = opts.profile ?? new PingFederateProfile();
83
105
  this.#metadata = opts.metadata ?? null;
106
+ this.#staticMetadata = this.#metadata !== null;
107
+ this.#evidenceAudience = opts.evidenceAudience;
84
108
  this.#evidence = opts.evidence;
85
109
  this.#authorizationDetails = opts.authorizationDetails;
86
110
  this.#operator = opts.operator && Object.keys(opts.operator).length > 0 ? opts.operator : undefined;
87
111
  this.#challengeAtToken = opts.challengeAtToken ?? true;
88
112
  this.#algorithm = opts.algorithm ?? "ES256";
89
113
  this.#skew = opts.expirySkewSeconds ?? DEFAULT_EXPIRY_SKEW_SECONDS;
114
+ if (!Number.isFinite(this.#skew) || this.#skew < 0) {
115
+ throw new RangeError(`expirySkewSeconds must be a finite number, not negative: ${this.#skew}`);
116
+ }
90
117
  this.#timeoutMs = opts.timeoutMs ?? 20_000;
91
- this.#popMethod = opts.popMethod ?? "attestation_pop_jwt";
118
+ this.#popMethod = checkPopMethod(opts.popMethod);
92
119
  this.#requireChallenge = opts.requireChallenge ?? true;
93
120
  this.#allowInsecure = opts.allowInsecure ?? false;
94
121
  this.#logger = opts.logger ?? NOOP_LOGGER;
@@ -99,6 +126,14 @@ export class ClientAttestation {
99
126
  this.baseUrl = normalizeBaseUrl(url);
100
127
  this.#checkSecure(this.baseUrl, "attesterUrl");
101
128
  }
129
+ // A client given an evidence source is set up to mint, and a client that discovers its attester needs the
130
+ // pin to mint: without it the discovery document would choose the audience the workload's identity token
131
+ // is for. Refused here, at startup, not at the first token(); last, so a bad URL or option is reported as
132
+ // itself first. A pin-less client with auto-detected evidence still builds (attesterMetadata() needs no
133
+ // pin); runFlow refuses its token().
134
+ if (this.#evidence != null && !this.#evidenceAudience && !this.#staticMetadata) {
135
+ throw new ClientAttestationError(PIN_REQUIRED);
136
+ }
102
137
  }
103
138
  // ── public API ──────────────────────────────────────────────────────────────────────────────────
104
139
  /** Return a valid access token, minting a new one only when the cached one is near expiry. */
@@ -121,16 +156,54 @@ export class ClientAttestation {
121
156
  * - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
122
157
  * token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
123
158
  * url's origin returned, if any.
159
+ *
160
+ * `url` must be https, like every other URL the client sends a credential to (loopback hosts and
161
+ * `allowInsecure` excepted).
162
+ *
163
+ * Pass the exact URL your HTTP client sends: the proof is only valid for the `htu` it names. A url with
164
+ * a backslash, ASCII whitespace or a control character before its query or fragment is refused with a
165
+ * {@link ClientAttestationError}, because URL parsers disagree on where it goes. For a DPoP-bound token,
166
+ * so is a url that is not an absolute http(s) URL with a valid ASCII host, port and path; with
167
+ * `popMethod: "dpop_combined"` that is checked before the token is fetched. The path is signed as
168
+ * written, so pass `new URL(u).href` (what `fetch` sends) rather than a hand-written string: `fetch` may
169
+ * escape characters (a `{`, say) that the string leaves as they are. {@link fetch} does this for you.
124
170
  */
125
171
  async requestHeaders(method, url, opts = {}) {
172
+ return this.#requestHeaders(method, url, opts);
173
+ }
174
+ /**
175
+ * {@link requestHeaders}, with the proof carrying `nonce` when given (the nonce of the challenge being
176
+ * answered) instead of the origin's remembered one.
177
+ */
178
+ async #requestHeaders(method, url, opts, nonce) {
179
+ this.#checkSecure(url, "resource URL");
180
+ // A client configured for DPoP checks the whole htu before fetching the token, so a URL with no htu
181
+ // never costs a mint and token request on a cold cache. Otherwise only what parsers disagree on is
182
+ // refused up front: a Bearer token carries no htu.
183
+ try {
184
+ if (this.#popMethod === DPOP_COMBINED)
185
+ normalizeHtu(url);
186
+ else
187
+ checkHtu(url);
188
+ }
189
+ catch (err) {
190
+ throw new ClientAttestationError(err.message);
191
+ }
126
192
  const state = await this.#current(opts);
127
193
  const headers = { Authorization: `${scheme(state)} ${state.accessToken}` };
128
194
  if (isDpop(state)) {
195
+ // Also for a DPoP token from a client not configured for DPoP (the AS chose the token type).
196
+ try {
197
+ normalizeHtu(url);
198
+ }
199
+ catch (err) {
200
+ throw new ClientAttestationError(err.message);
201
+ }
129
202
  headers[DPOP_HEADER] = await new DpopProofBuilder(state.key)
130
203
  .method(method)
131
204
  .uri(url)
132
205
  .accessToken(state.accessToken)
133
- .nonce(this.#nonces.get(originOf(url)) ?? null)
206
+ .nonce(nonce ?? this.#nonces.get(originOf(url)) ?? null)
134
207
  .build();
135
208
  }
136
209
  return headers;
@@ -144,14 +217,17 @@ export class ClientAttestation {
144
217
  */
145
218
  async fetch(input, init = {}) {
146
219
  const isRequest = typeof Request !== "undefined" && input instanceof Request;
147
- const url = isRequest ? input.url : String(input);
220
+ // The proof names the URL as fetch parses it (WHATWG), which is where the request goes.
221
+ const url = isRequest ? input.url : parsedHref(String(input));
148
222
  const method = (init.method ?? (isRequest ? input.method : "GET")).toUpperCase();
149
223
  // A Request's own body is cloned so the retry can send it again.
150
224
  const nextInput = () => (isRequest ? input.clone() : input);
151
225
  let dpopBound = false;
152
- const send = async () => {
226
+ const send = async (nonce) => {
153
227
  const headers = new Headers(init.headers ?? (isRequest ? input.headers : undefined));
154
- const auth = await this.requestHeaders(method, url);
228
+ const auth = nonce === undefined
229
+ ? await this.requestHeaders(method, url)
230
+ : await this.#requestHeaders(method, url, {}, nonce);
155
231
  dpopBound = DPOP_HEADER in auth;
156
232
  for (const [name, value] of Object.entries(auth))
157
233
  headers.set(name, value);
@@ -160,14 +236,24 @@ export class ClientAttestation {
160
236
  const first = await send();
161
237
  if (!dpopBound)
162
238
  return first;
163
- const nonce = first.headers.get(DPOP_NONCE_HEADER);
239
+ const nonce = usableNonce(first.headers.get(DPOP_NONCE_HEADER));
164
240
  if (nonce)
165
- this.#nonces.set(originOf(url), nonce);
166
- if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
241
+ this.#rememberNonce(url, nonce);
242
+ if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first, this.#timeoutMs)))
167
243
  return first;
168
244
  emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(url), status: first.status, at: "resource" });
169
245
  await first.body?.cancel().catch(() => undefined);
170
- return send();
246
+ // Answer this challenge with its own nonce, not one a concurrent response stored since.
247
+ return send(nonce);
248
+ }
249
+ /** Store `nonce` as the origin's latest, forgetting the origin stored longest ago over the cap. */
250
+ #rememberNonce(url, nonce) {
251
+ const key = originOf(url);
252
+ this.#nonces.delete(key);
253
+ if (this.#nonces.size >= MAX_DPOP_NONCE_ORIGINS) {
254
+ this.#nonces.delete(this.#nonces.keys().next().value);
255
+ }
256
+ this.#nonces.set(key, nonce);
171
257
  }
172
258
  /**
173
259
  * The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
@@ -201,9 +287,10 @@ export class ClientAttestation {
201
287
  /** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
202
288
  async #current(opts) {
203
289
  const state = this.#state;
204
- if (!opts.forceRefresh && state && Date.now() < state.expiresAt) {
290
+ const now = monotonicMs();
291
+ if (!opts.forceRefresh && state && now < state.expiresAt) {
205
292
  emit(this.#logger, "debug", "token.cache_hit", {
206
- expires_in: Math.max(0, Math.round((state.expiresAt - Date.now()) / 1000)),
293
+ expires_in: Math.max(0, Math.round((state.expiresAt - now) / 1000)),
207
294
  });
208
295
  return state;
209
296
  }
@@ -219,27 +306,38 @@ export class ClientAttestation {
219
306
  }
220
307
  async #refresh() {
221
308
  const { response, key } = await this.#runFlow();
222
- const expiresIn = response.expires_in;
309
+ const lifetime = tokenLifetime(response.expires_in);
223
310
  const state = {
224
311
  accessToken: response.access_token,
225
312
  tokenType: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
226
313
  key,
227
- expiresAt: expiresIn ? Date.now() + (Number(expiresIn) - this.#skew) * 1000 : 0,
314
+ expiresAt: lifetime !== undefined ? monotonicMs() + (lifetime - this.#skew) * 1000 : 0,
228
315
  };
229
316
  this.#state = state;
230
317
  return state;
231
318
  }
232
319
  async #runFlow() {
320
+ // The constructor refused anything else. A value that still isn't one of the two fails here, before
321
+ // any request, rather than falling back to a PoP JWT.
322
+ if (!POP_METHODS.includes(this.#popMethod)) {
323
+ throw new ClientAttestationError(`unsupported popMethod ${describe(this.#popMethod)}`);
324
+ }
325
+ if (!this.#evidenceAudience && !this.#staticMetadata)
326
+ throw new ClientAttestationError(PIN_REQUIRED);
233
327
  const meta = await this.attesterMetadata();
234
328
  const audience = meta.evidenceAudience;
235
329
  if (!audience) {
236
330
  throw new ClientAttestationError("attester discovery document has no evidence_audience");
237
331
  }
332
+ if (this.#evidenceAudience && audience !== this.#evidenceAudience) {
333
+ throw new ClientAttestationError(`the attester asks for evidence for ${JSON.stringify(audience)}, but evidenceAudience is ` +
334
+ JSON.stringify(this.#evidenceAudience));
335
+ }
238
336
  const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
239
337
  const svid = await evidenceSource.fetch(audience);
240
338
  emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
241
339
  const instanceKey = await SigningKeyPair.generate(this.#algorithm);
242
- const challenge = await this.#fetchChallenge(meta.challengeEndpoint);
340
+ const challenge = await this.#fetchChallenge(meta.challengeEndpoint, "attester");
243
341
  const { attestation } = await this.#profile.mint(this.#transport(), meta, new MintRequest({
244
342
  instanceKey,
245
343
  evidence: svid,
@@ -263,53 +361,77 @@ export class ClientAttestation {
263
361
  // Falling back to the token endpoint URL only works when the dialed URL matches PF's issuer (no
264
362
  // proxy in between) — so prefer the advertised value.
265
363
  const popAudience = meta.popAudience || tokenEndpoint;
266
- // See `challengeAtToken`: some attesters consume the challenge the moment the mint's instance
267
- // proof uses it, and reject a token request that presents the same value a second time. Against
268
- // one of those, only the mint gets the challenge.
269
- const tokenChallenge = this.#challengeAtToken ? challenge : undefined;
364
+ // The challenge the token-endpoint proof carries: the authorization server's own, from its challenge
365
+ // endpoint (draft -11 section 6.3), when the metadata names one; else the attester's (see
366
+ // `challengeAtToken`: some attesters consume the challenge the moment the mint's instance proof uses
367
+ // it, and reject a token request that presents the same value a second time; against one of those,
368
+ // only the mint gets the challenge). A challenge the server demands in a refusal (section 6.1
369
+ // `use_attestation_challenge`, or RFC 9449 `use_dpop_nonce` in combined mode) wins over either.
370
+ const tokenChallenge = meta.tokenChallengeEndpoint
371
+ ? await this.#fetchChallenge(meta.tokenChallengeEndpoint, "authorization_server")
372
+ : this.#challengeAtToken
373
+ ? challenge
374
+ : undefined;
270
375
  const credential = new ClientAttestationCredential(attestation, instanceKey);
271
376
  const obtained = (response) => {
272
- const expiresIn = Number(response.expires_in);
377
+ const lifetime = tokenLifetime(response.expires_in);
273
378
  emit(this.#logger, "info", "token.obtained", {
274
379
  host: hostOf(tokenEndpoint),
275
380
  client_id: clientId,
276
381
  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,
382
+ expires_in: lifetime !== undefined ? Math.trunc(lifetime) : undefined,
278
383
  pop_method: this.#popMethod,
279
384
  });
280
385
  return { response, key: instanceKey };
281
386
  };
282
- if (this.#popMethod !== "dpop_combined") {
283
- const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
284
- return obtained(this.#tokenResponse(tokenEndpoint, await this.#postToken(tokenEndpoint, headers)));
387
+ if (this.#popMethod === POP_JWT) {
388
+ let res = await this.#postToken(tokenEndpoint, await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null));
389
+ // draft -11 section 6.1: a server that uses challenges refuses a request without a usable one as
390
+ // 400 use_attestation_challenge and always carries a fresh challenge in
391
+ // OAuth-Client-Attestation-Challenge. Send once more with that one. A refusal with no header
392
+ // (or one too long to be a challenge), any other error, or a second refusal is the error the
393
+ // caller sees: a server that keeps refusing is not retried in a loop.
394
+ const served = usableNonce(res.headers.get(CHALLENGE_HEADER));
395
+ if (res.status === 400 && served && jsonError(res.body) === USE_ATTESTATION_CHALLENGE) {
396
+ emit(this.#logger, "info", "pop.challenge_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
397
+ res = await this.#postToken(tokenEndpoint, await credential.popHeaders(clientId, popAudience, served));
398
+ }
399
+ return obtained(this.#tokenResponse(tokenEndpoint, res));
285
400
  }
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.
401
+ // DPoP combined, the only other value (checked at the top of the flow): the challenge rides as the
402
+ // DPoP nonce, and RFC 9449's nonce mechanism is the only challenge mechanism in this mode (draft -11
403
+ // section 5.2: no `challenge` claim, no OAuth-Client-Attestation-Challenge). If the AS demands its
404
+ // own nonce (400 use_dpop_nonce + DPoP-Nonce), retry once with that — the server's demand wins.
288
405
  let res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, tokenChallenge ?? null));
289
- const serverNonce = res.headers.get(DPOP_NONCE_HEADER);
406
+ const serverNonce = usableNonce(res.headers.get(DPOP_NONCE_HEADER));
290
407
  if (serverNonce)
291
- this.#nonces.set(originOf(tokenEndpoint), serverNonce);
408
+ this.#rememberNonce(tokenEndpoint, serverNonce);
292
409
  if (res.status === 400 && serverNonce && jsonError(res.body) === USE_DPOP_NONCE) {
293
410
  emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
294
411
  res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
295
412
  }
296
413
  return obtained(this.#tokenResponse(tokenEndpoint, res));
297
414
  }
298
- async #fetchChallenge(endpoint) {
415
+ /**
416
+ * `POST` a challenge endpoint - the attester's (CAS section 4.1, for the mint) or the authorization
417
+ * server's (draft -11 section 6.3, for the token request); both answer `{"attestation_challenge"}`.
418
+ */
419
+ async #fetchChallenge(endpoint, at) {
299
420
  if (!endpoint)
300
421
  return undefined;
301
- this.#checkSecure(endpoint, "challenge_endpoint");
302
- // The attester advertised a challenge, so minting without one weakens replay protection: fail
422
+ const step = at === "attester" ? "challenge" : "token_challenge";
423
+ this.#checkSecure(endpoint, STEP_LABELS[step]);
424
+ // The server advertised a challenge, so going on without one weakens replay protection: fail
303
425
  // closed unless the caller opted out with requireChallenge: false.
304
426
  let body;
305
427
  try {
306
- body = await this.#getJson("challenge", "POST", endpoint);
428
+ body = await this.#getJson(step, "POST", endpoint);
307
429
  }
308
430
  catch (err) {
309
431
  if (!(err instanceof ClientAttestationError))
310
432
  throw err;
311
433
  if (!this.#requireChallenge) {
312
- emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
434
+ emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint), at });
313
435
  return undefined;
314
436
  }
315
437
  throw new ClientAttestationError(`could not fetch the attestation challenge from ${endpoint}: ${err.message}`, {
@@ -320,11 +442,11 @@ export class ClientAttestation {
320
442
  }
321
443
  const challenge = body.attestation_challenge;
322
444
  if (typeof challenge === "string" && challenge) {
323
- emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint) });
445
+ emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint), at });
324
446
  return challenge;
325
447
  }
326
448
  if (!this.#requireChallenge) {
327
- emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
449
+ emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint), at });
328
450
  return undefined;
329
451
  }
330
452
  throw new ClientAttestationError(`challenge endpoint ${endpoint} returned no attestation_challenge`);
@@ -362,18 +484,7 @@ export class ClientAttestation {
362
484
  }
363
485
  /** Refuse a non-https URL unless it is loopback or the caller passed `allowInsecure`. */
364
486
  #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)`);
487
+ checkSecure(url, what, this.#allowInsecure);
377
488
  }
378
489
  async #getJson(step, method, url, jsonBody) {
379
490
  const headers = { Accept: "application/json" };
@@ -406,30 +517,63 @@ export class ClientAttestation {
406
517
  }
407
518
  }
408
519
  }
409
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
410
520
  function tokenResponse(res) {
411
521
  if (!res.ok) {
412
522
  throw new ClientAttestationError(`token endpoint returned HTTP ${res.status}`, {
413
523
  status: res.status,
414
- body: res.body,
524
+ body: snippet(res.body),
415
525
  });
416
526
  }
417
527
  const parsed = jsonOk(res, { allowEmpty: true });
418
528
  if (!parsed.access_token) {
419
- throw new ClientAttestationError("token endpoint returned no access_token", { body: res.body });
529
+ // No body: a 2xx token response may hold the token under another name.
530
+ throw new ClientAttestationError("token endpoint returned no access_token");
420
531
  }
421
532
  return parsed;
422
533
  }
534
+ /**
535
+ * How long to cache a token, in seconds: `expires_in` as a JSON number, or a string of decimal digits,
536
+ * capped at {@link MAX_TOKEN_LIFETIME_SECONDS}. Anything else - missing, null, not a number, not finite,
537
+ * zero or negative - is undefined, and the token is not cached, as with no `expires_in` at all.
538
+ */
539
+ function tokenLifetime(expiresIn) {
540
+ let seconds;
541
+ if (typeof expiresIn === "number")
542
+ seconds = expiresIn;
543
+ else if (typeof expiresIn === "string" && /^[0-9]+(\.[0-9]+)?$/.test(expiresIn))
544
+ seconds = Number(expiresIn);
545
+ else
546
+ return undefined;
547
+ if (!Number.isFinite(seconds) || seconds <= 0)
548
+ return undefined;
549
+ return Math.min(seconds, MAX_TOKEN_LIFETIME_SECONDS);
550
+ }
423
551
  function isDpop(state) {
424
552
  return state.tokenType.toLowerCase() === "dpop";
425
553
  }
426
554
  function scheme(state) {
427
555
  return isDpop(state) ? "DPoP" : "Bearer";
428
556
  }
429
- /** `scheme://host[:port]` of a URL, the key DPoP nonces are remembered under. */
557
+ /** A `DPoP-Nonce` value, or null when there is none or it is longer than {@link MAX_DPOP_NONCE_LENGTH}. */
558
+ function usableNonce(nonce) {
559
+ return nonce && nonce.length <= MAX_DPOP_NONCE_LENGTH ? nonce : null;
560
+ }
561
+ /** `url` as WHATWG URL parsing (and so fetch) serialises it, or as given when it does not parse. */
562
+ function parsedHref(url) {
563
+ try {
564
+ return new URL(url).href;
565
+ }
566
+ catch {
567
+ return url;
568
+ }
569
+ }
570
+ /**
571
+ * `scheme://host[:port]` of a URL as its `htu` names it ({@link htuParts}), the key DPoP nonces are
572
+ * remembered under. A URL that rule refuses is its own key.
573
+ */
430
574
  function originOf(url) {
431
575
  try {
432
- return new URL(url).origin;
576
+ return htuParts(url)[0];
433
577
  }
434
578
  catch {
435
579
  return url;
@@ -453,18 +597,65 @@ function replayable(body) {
453
597
  }
454
598
  /**
455
599
  * 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"}`.
600
+ * section 8 (and some resources) use a 400 JSON `{"error":"use_dpop_nonce"}`. The 400 body is read from a
601
+ * clone, at most {@link MAX_NONCE_CHALLENGE_BODY} bytes and for at most `timeoutMs`, so the caller still
602
+ * gets the whole body.
457
603
  */
458
- async function isNonceChallenge(res) {
604
+ async function isNonceChallenge(res, timeoutMs) {
459
605
  if (res.status === 401) {
460
- const challenge = res.headers.get("WWW-Authenticate") ?? "";
461
- return /(^|,)\s*DPoP\b/i.test(challenge) && /error\s*=\s*"?use_dpop_nonce"?/i.test(challenge);
606
+ // fetch's Headers hands back the fields combined as one list (RFC 9110 section 5.3)
607
+ const challenge = res.headers.get("WWW-Authenticate");
608
+ return challenge !== null && isDpopNonceChallenge([challenge]);
462
609
  }
463
610
  if (res.status === 400) {
464
- return jsonError(await res.clone().text()) === USE_DPOP_NONCE;
611
+ const body = await peekBody(res, MAX_NONCE_CHALLENGE_BODY, timeoutMs);
612
+ return body !== undefined && jsonError(body) === USE_DPOP_NONCE;
465
613
  }
466
614
  return false;
467
615
  }
616
+ /**
617
+ * The text of `res`'s body read from a clone, or undefined when it is longer than `maxBytes`, fails, or is
618
+ * not complete within `timeoutMs`. The clone is cancelled once read, so no more than about `maxBytes` is
619
+ * buffered for the caller's copy.
620
+ */
621
+ async function peekBody(res, maxBytes, timeoutMs) {
622
+ const reader = res.clone().body?.getReader();
623
+ if (!reader)
624
+ return "";
625
+ let timer;
626
+ const expired = new Promise((resolve) => {
627
+ timer = setTimeout(() => resolve(undefined), timeoutMs);
628
+ });
629
+ const chunks = [];
630
+ let total = 0;
631
+ try {
632
+ for (;;) {
633
+ const next = await Promise.race([reader.read(), expired]);
634
+ if (next === undefined)
635
+ return undefined;
636
+ if (next.done)
637
+ break;
638
+ total += next.value.byteLength;
639
+ if (total > maxBytes)
640
+ return undefined;
641
+ chunks.push(next.value);
642
+ }
643
+ }
644
+ catch {
645
+ return undefined;
646
+ }
647
+ finally {
648
+ clearTimeout(timer);
649
+ reader.cancel().catch(() => undefined);
650
+ }
651
+ const all = new Uint8Array(total);
652
+ let offset = 0;
653
+ for (const chunk of chunks) {
654
+ all.set(chunk, offset);
655
+ offset += chunk.byteLength;
656
+ }
657
+ return utf8Decode(all);
658
+ }
468
659
  /**
469
660
  * Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
470
661
  * bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
@@ -483,6 +674,37 @@ function sortKeys(value) {
483
674
  }
484
675
  return value;
485
676
  }
677
+ /**
678
+ * The PoP method to use. `undefined`, `null` or `""` (unset) is the default; any other value must be
679
+ * exactly one of the two the draft names (case-sensitive), so a misspelt value can't quietly send a PoP
680
+ * JWT (vectors/pop-method.json).
681
+ */
682
+ function checkPopMethod(popMethod) {
683
+ if (popMethod === undefined || popMethod === null || popMethod === "")
684
+ return POP_JWT;
685
+ const known = POP_METHODS.find((name) => name === popMethod);
686
+ if (known === undefined) {
687
+ throw new RangeError(`popMethod must be "${POP_JWT}" or "${DPOP_COMBINED}", got ${describe(popMethod)}`);
688
+ }
689
+ return known;
690
+ }
691
+ /** A value for an error message: JSON where that works, never a throw (BigInt, a cycle, a symbol). */
692
+ function describe(value) {
693
+ try {
694
+ const json = JSON.stringify(value);
695
+ if (json !== undefined)
696
+ return json;
697
+ }
698
+ catch {
699
+ // fall through
700
+ }
701
+ try {
702
+ return typeof value === "bigint" ? `${value}n` : String(value);
703
+ }
704
+ catch {
705
+ return Object.prototype.toString.call(value);
706
+ }
707
+ }
486
708
  /** @internal */
487
709
  export function normalizeBaseUrl(attesterUrl) {
488
710
  let host = (attesterUrl ?? "").trim().replace(/\/+$/, "");