@idpartners/client-attestation-sdk 1.2.0 → 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.
@@ -7,8 +7,14 @@ export interface AttesterMetadata {
7
7
  attestationEndpoint?: string;
8
8
  /** The authorization server's token endpoint. A profile fills in its own fallback. */
9
9
  tokenEndpoint?: string;
10
- /** The draft's challenge endpoint, if the attester issues challenges. */
10
+ /** The attester's challenge endpoint (CAS section 4.1), if the attester issues challenges for the mint. */
11
11
  challengeEndpoint?: string;
12
+ /**
13
+ * The authorization server's challenge endpoint (draft -11 section 6.3), if it issues challenges for
14
+ * the token request: `POST`ed before each token request, and its `attestation_challenge` carried as
15
+ * the PoP JWT's `challenge` claim or the DPoP proof's `nonce`. PingFederate: `/federation/attestation-challenge`.
16
+ */
17
+ tokenChallengeEndpoint?: string;
12
18
  /** The Client Attestation PoP `aud` (the AS issuer); falls back to the token endpoint URL. */
13
19
  popAudience?: string;
14
20
  /** The raw discovery document, when there was one; returned by `ClientAttestation.discovery()`. */
@@ -85,8 +91,8 @@ export declare const INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt"
85
91
  * The attester this SDK was built against (PingFederate), and the default.
86
92
  *
87
93
  * - Discovery: `GET {attesterUrl}/.well-known/client-attester`, a JSON object with `evidence_audience`,
88
- * `attestation_endpoint`, `challenge_endpoint`, `token_endpoint` and `pop_audience`. With no
89
- * `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
94
+ * `attestation_endpoint`, `challenge_endpoint`, `token_endpoint`, `token_challenge_endpoint` and
95
+ * `pop_audience`. With no `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
90
96
  * - Mint: `POST attestation_endpoint` with JSON `{[client_id,] instance_key, svid, proof
91
97
  * [, authorization_details][, operator]}`, where `proof` is `{aud: evidence_audience, jti, iat
92
98
  * [, challenge]}` signed by the instance key with `typ` `oauth-attestation-instance-proof+jwt`. The
package/dist/attester.js CHANGED
@@ -86,8 +86,8 @@ export const INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt";
86
86
  * The attester this SDK was built against (PingFederate), and the default.
87
87
  *
88
88
  * - Discovery: `GET {attesterUrl}/.well-known/client-attester`, a JSON object with `evidence_audience`,
89
- * `attestation_endpoint`, `challenge_endpoint`, `token_endpoint` and `pop_audience`. With no
90
- * `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
89
+ * `attestation_endpoint`, `challenge_endpoint`, `token_endpoint`, `token_challenge_endpoint` and
90
+ * `pop_audience`. With no `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
91
91
  * - Mint: `POST attestation_endpoint` with JSON `{[client_id,] instance_key, svid, proof
92
92
  * [, authorization_details][, operator]}`, where `proof` is `{aud: evidence_audience, jti, iat
93
93
  * [, challenge]}` signed by the instance key with `typ` `oauth-attestation-instance-proof+jwt`. The
@@ -109,6 +109,7 @@ export class PingFederateProfile {
109
109
  // discovery document still works from the host alone.
110
110
  tokenEndpoint: str("token_endpoint") ?? attesterUrl + PingFederateProfile.DEFAULT_TOKEN_PATH,
111
111
  challengeEndpoint: str("challenge_endpoint"),
112
+ tokenChallengeEndpoint: str("token_challenge_endpoint"),
112
113
  popAudience: str("pop_audience"),
113
114
  document: doc,
114
115
  };
@@ -154,6 +155,7 @@ export function metadataDocument(metadata) {
154
155
  ["attestation_endpoint", metadata.attestationEndpoint],
155
156
  ["challenge_endpoint", metadata.challengeEndpoint],
156
157
  ["token_endpoint", metadata.tokenEndpoint],
158
+ ["token_challenge_endpoint", metadata.tokenChallengeEndpoint],
157
159
  ["pop_audience", metadata.popAudience],
158
160
  ];
159
161
  for (const [key, value] of fields)
@@ -2,6 +2,8 @@ import { SigningKeyPair } from "./signing-key-pair.js";
2
2
  export declare const ATTESTATION_HEADER = "OAuth-Client-Attestation";
3
3
  export declare const POP_HEADER = "OAuth-Client-Attestation-PoP";
4
4
  export declare const DPOP_HEADER = "DPoP";
5
+ /** The response header a `use_attestation_challenge` refusal carries a fresh challenge in (draft -11 section 6.1). */
6
+ export declare const CHALLENGE_HEADER = "OAuth-Client-Attestation-Challenge";
5
7
  /**
6
8
  * The Attester-issued attestation JWT plus the instance key it is bound to. Produces the
7
9
  * token-request headers — PoP-JWT mode or DPoP combined mode — minting a fresh proof each call.
@@ -6,6 +6,8 @@ import { requireText } from "./signing-key-pair.js";
6
6
  export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
7
7
  export const POP_HEADER = "OAuth-Client-Attestation-PoP";
8
8
  export const DPOP_HEADER = "DPoP";
9
+ /** The response header a `use_attestation_challenge` refusal carries a fresh challenge in (draft -11 section 6.1). */
10
+ export const CHALLENGE_HEADER = "OAuth-Client-Attestation-Challenge";
9
11
  /**
10
12
  * The Attester-issued attestation JWT plus the instance key it is bound to. Produces the
11
13
  * token-request headers — PoP-JWT mode or DPoP combined mode — minting a fresh proof each call.
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  export { SigningKeyPair } from "./signing-key-pair.js";
9
9
  export { ClientAttestationBuilder, DpopProofBuilder, PopBuilder, ATTESTATION_TYP, POP_TYP, DPOP_TYP, } from "./builders.js";
10
- export { ClientAttestationCredential, ATTESTATION_HEADER, POP_HEADER, DPOP_HEADER, } from "./credential.js";
10
+ export { ClientAttestationCredential, ATTESTATION_HEADER, POP_HEADER, DPOP_HEADER, CHALLENGE_HEADER, } from "./credential.js";
11
11
  export { OpenBaoTransitSigner } from "./signer.js";
12
12
  export type { JwsSigner } from "./signer.js";
13
13
  export { AttestationIssuanceClient, IssuedAttestation, IssuanceError, INSTANCE_PROOF_TYP, } from "./issuance.js";
@@ -21,6 +21,10 @@ export type { AttesterMetadata, AttesterProfile, AttesterTransport, MintResult }
21
21
  export type { TransportFailure } from "./errors.js";
22
22
  export { NOOP_LOGGER } from "./logging.js";
23
23
  export type { Logger, LogAttributes, LogLevel } from "./logging.js";
24
- export declare const VERSION = "1.2.0";
25
- /** draft-ietf-oauth-attestation-based-client-auth revision this SDK implements. */
26
- export declare const DRAFT_REVISION = "10";
24
+ export declare const VERSION = "1.4.0";
25
+ /**
26
+ * draft-ietf-oauth-attestation-based-client-auth revision this port implements. -11 (the Go, Java and
27
+ * Python ports are at -10): the authorization server's challenge endpoint and the
28
+ * `use_attestation_challenge` retry, with combined mode on RFC 9449 nonces alone.
29
+ */
30
+ export declare const DRAFT_REVISION = "11";
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  export { SigningKeyPair } from "./signing-key-pair.js";
9
9
  export { ClientAttestationBuilder, DpopProofBuilder, PopBuilder, ATTESTATION_TYP, POP_TYP, DPOP_TYP, } from "./builders.js";
10
- export { ClientAttestationCredential, ATTESTATION_HEADER, POP_HEADER, DPOP_HEADER, } from "./credential.js";
10
+ export { ClientAttestationCredential, ATTESTATION_HEADER, POP_HEADER, DPOP_HEADER, CHALLENGE_HEADER, } from "./credential.js";
11
11
  export { OpenBaoTransitSigner } from "./signer.js";
12
12
  export { AttestationIssuanceClient, IssuedAttestation, IssuanceError, INSTANCE_PROOF_TYP, } from "./issuance.js";
13
13
  export { StaticSvidSource, HttpSvidSource, SpireWorkloadApiSvidSource, } from "./svid-source.js";
@@ -15,6 +15,10 @@ export { EvidenceNotFound, FileTokenSvidSource, GoogleMetadataSvidSource, autoDe
15
15
  export { ClientAttestation, ClientAttestationError, TransportError, DEFAULT_EXPIRY_SKEW_SECONDS, } from "./token-source.js";
16
16
  export { MintRequest, PingFederateProfile } from "./attester.js";
17
17
  export { NOOP_LOGGER } from "./logging.js";
18
- export const VERSION = "1.2.0";
19
- /** draft-ietf-oauth-attestation-based-client-auth revision this SDK implements. */
20
- export const DRAFT_REVISION = "10";
18
+ export const VERSION = "1.4.0";
19
+ /**
20
+ * draft-ietf-oauth-attestation-based-client-auth revision this port implements. -11 (the Go, Java and
21
+ * Python ports are at -10): the authorization server's challenge endpoint and the
22
+ * `use_attestation_challenge` retry, with combined mode on RFC 9449 nonces alone.
23
+ */
24
+ export const DRAFT_REVISION = "11";
@@ -34,9 +34,10 @@ export declare class HttpSvidSource implements SvidSource {
34
34
  * and at most 1 MiB in all (a helper the agent started can inherit the pipe and keep writing to it or hold
35
35
  * it open), then the pipe is closed. On exit status 0 what was read is parsed; on any other status, or if
36
36
  * the agent was killed, `fetch` rejects. So output a helper writes after the agent has exited never turns a
37
- * success into a failure, and never keeps `fetch` waiting longer than that. If reading stopped at the
38
- * 1 MiB or at the end of the 250 ms, the last line may be cut off, so a token is only taken from a line
39
- * that ends in a newline.
37
+ * success into a failure, and never keeps `fetch` waiting longer than that. A token is only taken from a
38
+ * line that ends in a newline, whether or not stdout was read to its end: if reading stopped at the 1 MiB
39
+ * or at the end of the 250 ms the last line may be cut off, and no port can tell that from an agent whose
40
+ * last line lacks a newline (real spire-agent output always ends in one), so every port refuses it.
40
41
  *
41
42
  * Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
42
43
  * a `ClientAttestationError` on a runtime without it.
@@ -65,9 +65,10 @@ export class HttpSvidSource {
65
65
  * and at most 1 MiB in all (a helper the agent started can inherit the pipe and keep writing to it or hold
66
66
  * it open), then the pipe is closed. On exit status 0 what was read is parsed; on any other status, or if
67
67
  * the agent was killed, `fetch` rejects. So output a helper writes after the agent has exited never turns a
68
- * success into a failure, and never keeps `fetch` waiting longer than that. If reading stopped at the
69
- * 1 MiB or at the end of the 250 ms, the last line may be cut off, so a token is only taken from a line
70
- * that ends in a newline.
68
+ * success into a failure, and never keeps `fetch` waiting longer than that. A token is only taken from a
69
+ * line that ends in a newline, whether or not stdout was read to its end: if reading stopped at the 1 MiB
70
+ * or at the end of the 250 ms the last line may be cut off, and no port can tell that from an agent whose
71
+ * last line lacks a newline (real spire-agent output always ends in one), so every port refuses it.
71
72
  *
72
73
  * Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
73
74
  * a `ClientAttestationError` on a runtime without it.
@@ -86,7 +87,7 @@ export class SpireWorkloadApiSvidSource {
86
87
  const { spawn } = await importNodeBuiltin("child_process", "SpireWorkloadApiSvidSource");
87
88
  // stdout only: stderr goes to the null device, so a log line or version string the agent writes there
88
89
  // is never parsed, and a flood of it is never held in memory.
89
- const { stdout, complete } = await new Promise((resolve, reject) => {
90
+ const stdout = await new Promise((resolve, reject) => {
90
91
  const args = ["api", "fetch", "jwt", "-audience", audience, "-socketPath", this.socketPath];
91
92
  const child = spawn(this.spireAgentBin, args, {
92
93
  stdio: ["ignore", "pipe", "ignore"],
@@ -96,7 +97,6 @@ export class SpireWorkloadApiSvidSource {
96
97
  const chunks = [];
97
98
  let size = 0;
98
99
  let over = false; // past the cap while the agent was running
99
- let ended = false; // stdout was read to its end
100
100
  let exited;
101
101
  let settled = false;
102
102
  let grace;
@@ -114,7 +114,7 @@ export class SpireWorkloadApiSvidSource {
114
114
  reject(Object.assign(new Error(message), { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" }));
115
115
  }
116
116
  else if (code === 0) {
117
- resolve({ stdout: concatUtf8(chunks, size), complete: ended });
117
+ resolve(concatUtf8(chunks, size));
118
118
  }
119
119
  else {
120
120
  reject(spireAgentExitFailure(code, signal, child.killed));
@@ -141,10 +141,6 @@ export class SpireWorkloadApiSvidSource {
141
141
  size += chunk.length;
142
142
  chunks.push(chunk);
143
143
  });
144
- child.stdout?.on("end", () => {
145
- if (!settled)
146
- ended = true;
147
- });
148
144
  child.stdout?.on("error", () => { }); // the outcome comes from "exit" or "close"
149
145
  child.on("error", (err) => {
150
146
  if (settled)
@@ -164,9 +160,10 @@ export class SpireWorkloadApiSvidSource {
164
160
  });
165
161
  child.on("close", (code, signal) => settle(code, signal));
166
162
  });
167
- // If reading stopped before the end of stdout (at the cap or the end of the grace period), the line
168
- // after the last newline may have been cut off.
169
- const svid = parseSpireJwtOutput(complete ? stdout : stdout.slice(0, stdout.lastIndexOf("\n") + 1));
163
+ // A token is only taken from a line that ends in a newline: the line after the last newline may have been
164
+ // cut off (reading stopped at the cap or the end of the grace period), and whether stdout was read to its
165
+ // end can't tell that apart from an agent whose last line lacks a newline, so every port refuses it.
166
+ const svid = parseSpireJwtOutput(stdout.slice(0, stdout.lastIndexOf("\n") + 1));
170
167
  if (svid)
171
168
  return svid;
172
169
  // The output may hold a token or bundle that failed to parse, so the error gives only its size.
@@ -13,6 +13,7 @@ export interface AttesterDiscovery {
13
13
  attestation_endpoint?: string;
14
14
  challenge_endpoint?: string;
15
15
  token_endpoint?: string;
16
+ token_challenge_endpoint?: string;
16
17
  pop_audience?: string;
17
18
  }
18
19
  /** Options for {@link ClientAttestation}. */
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { base64url } from "jose";
23
23
  import { checkHtu, DpopProofBuilder, htuParts, normalizeHtu } from "./builders.js";
24
- import { ClientAttestationCredential, DPOP_HEADER } from "./credential.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";
@@ -29,6 +29,7 @@ import { checkSecure, jsonOk, request, snippet } from "./http.js";
29
29
  import { emit, failureReason, hostOf, NOOP_LOGGER, urlParts } from "./logging.js";
30
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. */
@@ -41,6 +42,8 @@ const PIN_REQUIRED = "evidenceAudience is required: set it to the audience your
41
42
  export { ClientAttestationError, TransportError } from "./errors.js";
42
43
  const USE_DPOP_NONCE = "use_dpop_nonce";
43
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";
44
47
  /** At most this much of a 400 body is read to look for `use_dpop_nonce`; a longer body is not a challenge. */
45
48
  const MAX_NONCE_CHALLENGE_BODY = 64 * 1024;
46
49
  /** RFC 9449 nonces are short opaque strings: a longer `DPoP-Nonce` is ignored, as if there were none. */
@@ -57,6 +60,7 @@ const STEP_LABELS = {
57
60
  discover: "attesterUrl",
58
61
  mint: "attestation_endpoint",
59
62
  challenge: "challenge_endpoint",
63
+ token_challenge: "token_challenge_endpoint",
60
64
  };
61
65
  /**
62
66
  * Gets and caches an access token using client attestation.
@@ -122,6 +126,14 @@ export class ClientAttestation {
122
126
  this.baseUrl = normalizeBaseUrl(url);
123
127
  this.#checkSecure(this.baseUrl, "attesterUrl");
124
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
+ }
125
137
  }
126
138
  // ── public API ──────────────────────────────────────────────────────────────────────────────────
127
139
  /** Return a valid access token, minting a new one only when the cached one is near expiry. */
@@ -325,7 +337,7 @@ export class ClientAttestation {
325
337
  const svid = await evidenceSource.fetch(audience);
326
338
  emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
327
339
  const instanceKey = await SigningKeyPair.generate(this.#algorithm);
328
- const challenge = await this.#fetchChallenge(meta.challengeEndpoint);
340
+ const challenge = await this.#fetchChallenge(meta.challengeEndpoint, "attester");
329
341
  const { attestation } = await this.#profile.mint(this.#transport(), meta, new MintRequest({
330
342
  instanceKey,
331
343
  evidence: svid,
@@ -349,10 +361,17 @@ export class ClientAttestation {
349
361
  // Falling back to the token endpoint URL only works when the dialed URL matches PF's issuer (no
350
362
  // proxy in between) — so prefer the advertised value.
351
363
  const popAudience = meta.popAudience || tokenEndpoint;
352
- // See `challengeAtToken`: some attesters consume the challenge the moment the mint's instance
353
- // proof uses it, and reject a token request that presents the same value a second time. Against
354
- // one of those, only the mint gets the challenge.
355
- 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;
356
375
  const credential = new ClientAttestationCredential(attestation, instanceKey);
357
376
  const obtained = (response) => {
358
377
  const lifetime = tokenLifetime(response.expires_in);
@@ -366,12 +385,23 @@ export class ClientAttestation {
366
385
  return { response, key: instanceKey };
367
386
  };
368
387
  if (this.#popMethod === POP_JWT) {
369
- const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
370
- return obtained(this.#tokenResponse(tokenEndpoint, await this.#postToken(tokenEndpoint, headers)));
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));
371
400
  }
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.
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.
375
405
  let res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, tokenChallenge ?? null));
376
406
  const serverNonce = usableNonce(res.headers.get(DPOP_NONCE_HEADER));
377
407
  if (serverNonce)
@@ -382,21 +412,26 @@ export class ClientAttestation {
382
412
  }
383
413
  return obtained(this.#tokenResponse(tokenEndpoint, res));
384
414
  }
385
- 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) {
386
420
  if (!endpoint)
387
421
  return undefined;
388
- this.#checkSecure(endpoint, "challenge_endpoint");
389
- // 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
390
425
  // closed unless the caller opted out with requireChallenge: false.
391
426
  let body;
392
427
  try {
393
- body = await this.#getJson("challenge", "POST", endpoint);
428
+ body = await this.#getJson(step, "POST", endpoint);
394
429
  }
395
430
  catch (err) {
396
431
  if (!(err instanceof ClientAttestationError))
397
432
  throw err;
398
433
  if (!this.#requireChallenge) {
399
- emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
434
+ emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint), at });
400
435
  return undefined;
401
436
  }
402
437
  throw new ClientAttestationError(`could not fetch the attestation challenge from ${endpoint}: ${err.message}`, {
@@ -407,11 +442,11 @@ export class ClientAttestation {
407
442
  }
408
443
  const challenge = body.attestation_challenge;
409
444
  if (typeof challenge === "string" && challenge) {
410
- emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint) });
445
+ emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint), at });
411
446
  return challenge;
412
447
  }
413
448
  if (!this.#requireChallenge) {
414
- emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
449
+ emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint), at });
415
450
  return undefined;
416
451
  }
417
452
  throw new ClientAttestationError(`challenge endpoint ${endpoint} returned no attestation_challenge`);
@@ -568,8 +603,9 @@ function replayable(body) {
568
603
  */
569
604
  async function isNonceChallenge(res, timeoutMs) {
570
605
  if (res.status === 401) {
571
- const challenge = res.headers.get("WWW-Authenticate") ?? "";
572
- 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]);
573
609
  }
574
610
  if (res.status === 400) {
575
611
  const body = await peekBody(res, MAX_NONCE_CHALLENGE_BODY, timeoutMs);
@@ -6,6 +6,8 @@ import type { JSONWebKeySet } from "jose";
6
6
  export declare const DEFAULT_ALGORITHMS: readonly string[];
7
7
  /** RFC 9068 section 2.1: a JWT access token's `typ` is `at+jwt` (the `application/` prefix is optional). */
8
8
  export declare const DEFAULT_TOKEN_TYPES: readonly string[];
9
+ /** How long keys fetched from `jwksUri` are still used after the last successful fetch: 24 hours. */
10
+ export declare const DEFAULT_JWKS_MAX_STALE_SECONDS: number;
9
11
  /** How the resource server authenticates to the AS when introspecting. */
10
12
  export type IntrospectionAuthMethod = "client_secret_basic" | "client_secret_post";
11
13
  /**
@@ -28,9 +30,11 @@ export interface IntrospectionConfig {
28
30
  * and optional introspection.
29
31
  *
30
32
  * `issuer` and `audiences` are required: a validator that skips either accepts tokens minted for someone
31
- * else. `acceptedTokenTypes` is checked against the JWT `typ` header when the token carries one, so an ID
32
- * token can't stand in for an access token. Pass `null` to turn that check off for an issuer that stamps
33
- * something other than `at+jwt`.
33
+ * else. `acceptedTokenTypes` is checked against the JWT `typ` header, so an ID token can't stand in for an
34
+ * access token: a token without `typ` is refused like one with the wrong `typ`. For an issuer that mints
35
+ * access tokens without `typ`, `allowMissingTokenType: true` accepts a missing `typ` while a wrong one is
36
+ * still refused. Pass `null` to turn the check off altogether, for an issuer that stamps something other
37
+ * than `at+jwt`.
34
38
  *
35
39
  * An explicitly empty `acceptedAlgorithms` or `acceptedTokenTypes` accepts nothing, and `leewaySeconds: 0`
36
40
  * allows no clock skew: only an omitted field takes the default. `none` and the `HS*` algorithms are
@@ -39,6 +43,12 @@ export interface IntrospectionConfig {
39
43
  * `jwksUri` and the introspection `endpoint` must use https unless their host is loopback (`localhost`,
40
44
  * `127.0.0.1`, `::1`) or `allowInsecure` is set: over plain http anyone on the path could serve their own
41
45
  * keys, or read the resource server's client secret.
46
+ *
47
+ * Keys fetched from `jwksUri` are refreshed after an hour, and a failed refresh keeps serving them for up to
48
+ * `jwksMaxStaleSeconds` after the last successful fetch (default {@link DEFAULT_JWKS_MAX_STALE_SECONDS}).
49
+ * Past that, every token fails with `key_not_found` (`signing keys unavailable`) until a fetch succeeds, so a
50
+ * retired key the issuer no longer publishes can't verify tokens indefinitely while the JWKS endpoint is
51
+ * unreachable. `null` removes the limit: availability over that protection.
42
52
  */
43
53
  export interface ValidatorConfig {
44
54
  issuer: string;
@@ -51,10 +61,21 @@ export interface ValidatorConfig {
51
61
  /** Clock skew allowed on `exp` / `nbf` / `iat`, in seconds. Defaults to 60. */
52
62
  leewaySeconds?: number;
53
63
  introspection?: IntrospectionConfig;
54
- /** Defaults to {@link DEFAULT_TOKEN_TYPES}; `null` disables the `typ` check. */
64
+ /** Defaults to {@link DEFAULT_TOKEN_TYPES}; a token without `typ` is refused. `null` disables the `typ` check. */
55
65
  acceptedTokenTypes?: string[] | null;
66
+ /**
67
+ * Accept a token without `typ` (or with `"typ": null`) while still refusing a wrong `typ`, for an issuer
68
+ * that mints access tokens without one. Defaults to `false`.
69
+ */
70
+ allowMissingTokenType?: boolean;
56
71
  /** Allow a plain-http `jwksUri` and introspection `endpoint` on non-loopback hosts. Defaults to `false`. */
57
72
  allowInsecure?: boolean;
73
+ /**
74
+ * How long keys fetched from `jwksUri` are still used when the refresh keeps failing, in seconds since the
75
+ * last successful fetch. A positive integer; defaults to {@link DEFAULT_JWKS_MAX_STALE_SECONDS}. `null`
76
+ * for no limit.
77
+ */
78
+ jwksMaxStaleSeconds?: number | null;
58
79
  }
59
80
  /** A {@link ValidatorConfig} with every optional field defaulted. */
60
81
  export interface ResolvedConfig {
@@ -68,4 +89,7 @@ export interface ResolvedConfig {
68
89
  introspection?: IntrospectionConfig;
69
90
  /** Normalised with {@link normaliseType}; `null` when the check is off. */
70
91
  acceptedTokenTypes: string[] | null;
92
+ allowMissingTokenType: boolean;
93
+ /** `null` for no limit. */
94
+ jwksMaxStaleSeconds: number | null;
71
95
  }
@@ -12,6 +12,8 @@ export const DEFAULT_ALGORITHMS = Object.freeze([
12
12
  ]);
13
13
  /** RFC 9068 section 2.1: a JWT access token's `typ` is `at+jwt` (the `application/` prefix is optional). */
14
14
  export const DEFAULT_TOKEN_TYPES = Object.freeze(["at+jwt"]);
15
+ /** How long keys fetched from `jwksUri` are still used after the last successful fetch: 24 hours. */
16
+ export const DEFAULT_JWKS_MAX_STALE_SECONDS = 24 * 3600;
15
17
  /**
16
18
  * `none` (in any case) and the HMAC algorithms are refused whatever `acceptedAlgorithms` says: an unsigned
17
19
  * token proves nothing, and an HMAC "signature" checked against an issuer's public key is forgeable by
@@ -84,6 +86,14 @@ export function resolveConfig(config) {
84
86
  if (typeof allowInsecure !== "boolean") {
85
87
  throw new Error("allowInsecure must be a boolean");
86
88
  }
89
+ const allowMissingTokenType = config.allowMissingTokenType ?? false;
90
+ if (typeof allowMissingTokenType !== "boolean") {
91
+ throw new Error("allowMissingTokenType must be a boolean");
92
+ }
93
+ const maxStale = config.jwksMaxStaleSeconds === undefined ? DEFAULT_JWKS_MAX_STALE_SECONDS : config.jwksMaxStaleSeconds;
94
+ if (maxStale !== null && (typeof maxStale !== "number" || !Number.isSafeInteger(maxStale) || maxStale <= 0)) {
95
+ throw new Error("jwksMaxStaleSeconds must be a positive integer, or null for no limit");
96
+ }
87
97
  if (config.jwksUri) {
88
98
  checkSecure(config.jwksUri, "jwksUri", allowInsecure);
89
99
  }
@@ -120,6 +130,8 @@ export function resolveConfig(config) {
120
130
  ? undefined
121
131
  : { ...config.introspection, authMethod: config.introspection.authMethod || "client_secret_basic" },
122
132
  acceptedTokenTypes: types === null ? null : stringList(types, "acceptedTokenTypes").map(normaliseType),
133
+ allowMissingTokenType,
134
+ jwksMaxStaleSeconds: maxStale,
123
135
  };
124
136
  }
125
137
  /** A value's type and size, never its content (it may be a secret). */
@@ -9,11 +9,11 @@ export { AccessTokenValidator } from "./validator.js";
9
9
  export type { HttpPost, Claims, ValidatorOptions } from "./validator.js";
10
10
  export type { HttpGet } from "./jwks.js";
11
11
  export type { Logger, LogAttributes, LogLevel } from "../logging.js";
12
- export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
12
+ export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES, DEFAULT_JWKS_MAX_STALE_SECONDS } from "./config.js";
13
13
  export type { ValidatorConfig, ResolvedConfig, IntrospectionConfig, IntrospectionAuthMethod, } from "./config.js";
14
14
  export type { ValidationResult } from "./result.js";
15
15
  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.2.0";
19
+ export declare const VALIDATOR_VERSION = "1.4.0";
@@ -6,7 +6,7 @@
6
6
  * `client_attestation_sdk.token_validator` package so every port reports the same verdict for the shared vectors.
7
7
  */
8
8
  export { AccessTokenValidator } from "./validator.js";
9
- export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
9
+ export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES, DEFAULT_JWKS_MAX_STALE_SECONDS } 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.2.0";
12
+ export const VALIDATOR_VERSION = "1.4.0";
@@ -17,6 +17,8 @@ export const MAX_JWKS_BYTES = 512 * 1024;
17
17
  export const DEFAULT_MIN_REFRESH_SECONDS = 30;
18
18
  /** @internal */
19
19
  export const DEFAULT_MAX_AGE_SECONDS = 3600;
20
+ /** How long fetched keys are still served after the last successful fetch (config `DEFAULT_JWKS_MAX_STALE_SECONDS`). @internal */
21
+ export const DEFAULT_MAX_STALE_SECONDS = 24 * 3600;
20
22
  const FETCH_TIMEOUT_MS = 10_000;
21
23
  const defaultGet = async (url) => {
22
24
  // the shared transport refuses redirects and caps the body at MAX_JWKS_BYTES while reading it
@@ -65,6 +67,7 @@ export class JwksProvider {
65
67
  #httpGet;
66
68
  #minRefreshMs;
67
69
  #maxAgeMs;
70
+ #maxStaleMs;
68
71
  #clock;
69
72
  #logger;
70
73
  /** The usable keys, in document order: kid-less JWKs are distinct keys, and the first of a duplicate kid wins. */
@@ -81,6 +84,12 @@ export class JwksProvider {
81
84
  this.#httpGet = opts.httpGet ?? defaultGet;
82
85
  this.#minRefreshMs = (opts.minRefreshSeconds ?? DEFAULT_MIN_REFRESH_SECONDS) * 1000;
83
86
  this.#maxAgeMs = (opts.maxAgeSeconds ?? DEFAULT_MAX_AGE_SECONDS) * 1000;
87
+ this.#maxStaleMs =
88
+ opts.maxStaleSeconds === undefined
89
+ ? DEFAULT_MAX_STALE_SECONDS * 1000
90
+ : opts.maxStaleSeconds === null
91
+ ? null
92
+ : opts.maxStaleSeconds * 1000;
84
93
  this.#clock = opts.clock ?? (() => performance.now());
85
94
  this.#logger = opts.logger ?? NOOP_LOGGER;
86
95
  if (opts.jwks != null) {
@@ -126,6 +135,11 @@ export class JwksProvider {
126
135
  if (!held || this.#stale()) {
127
136
  await this.#refresh(held);
128
137
  }
138
+ if (this.#expired()) {
139
+ // past the stale limit without a successful refresh: the keys held are not served
140
+ this.lastError ??= "JWKS fetch failed: cached keys are past the stale limit";
141
+ return undefined;
142
+ }
129
143
  }
130
144
  const entry = this.#lookup(kid, alg);
131
145
  if (entry === undefined) {
@@ -148,8 +162,21 @@ export class JwksProvider {
148
162
  }
149
163
  return this.#keys.length === 1 && allowed(this.#keys[0]) ? this.#keys[0] : undefined;
150
164
  }
165
+ /** Milliseconds since the last successful fetch, or `null` before one. */
166
+ #age() {
167
+ return this.#loadedAt === null ? null : this.#clock() - this.#loadedAt;
168
+ }
169
+ #pastLimit(age) {
170
+ return this.#maxStaleMs !== null && age !== null && age >= this.#maxStaleMs;
171
+ }
172
+ /** Whether the set should be refreshed: never fetched, older than the max age, or past the stale limit. */
151
173
  #stale() {
152
- return this.#loadedAt === null || this.#clock() - this.#loadedAt >= this.#maxAgeMs;
174
+ const age = this.#age();
175
+ return age === null || age >= this.#maxAgeMs || this.#pastLimit(age);
176
+ }
177
+ /** Whether the keys held are past the stale limit, and so not served until a fetch succeeds. */
178
+ #expired() {
179
+ return this.#pastLimit(this.#age());
153
180
  }
154
181
  /**
155
182
  * A caller that already holds a usable (if stale) key never waits for another request's fetch: it goes on
@@ -182,8 +209,15 @@ export class JwksProvider {
182
209
  reason: failureReason(exc),
183
210
  status: exc instanceof TransportError ? exc.status : undefined,
184
211
  });
185
- // keep serving the keys already held
212
+ // keep serving the keys already held, up to the stale limit
186
213
  this.lastError = `JWKS fetch failed: ${exc instanceof Error ? exc.message : String(exc)}`;
214
+ const age = this.#age();
215
+ if (this.#pastLimit(age)) {
216
+ emit(this.#logger, "warn", "jwks.stale", {
217
+ host: hostOf(this.#jwksUri),
218
+ age: Math.floor(age / 1000),
219
+ });
220
+ }
187
221
  }
188
222
  finally {
189
223
  this.#inFlight = null;
@@ -2,7 +2,7 @@
2
2
  * Access-token validation: JWT signature + claim checks, and optional RFC 7662 introspection.
3
3
  *
4
4
  * `validate` does local JWT validation in a fixed order — algorithm accepted, key resolvable,
5
- * signature, `typ` (when present), `iss`, `exp` present, `exp`, `nbf`, `iat` not in the future, audience,
5
+ * signature, `typ` (unless the check is off), `iss`, `exp` present, `exp`, `nbf`, `iat` not in the future, audience,
6
6
  * then scope — returning the first failure's stable error code. That order is part of the cross-language contract so every port reports the same verdict.
7
7
  * `validateActive` instead uses RFC 7662 introspection.
8
8
  */
@@ -57,6 +57,7 @@ export class AccessTokenValidator {
57
57
  jwks: this.config.jwks,
58
58
  jwksUri: this.config.jwksUri,
59
59
  httpGet: opts.httpGet,
60
+ maxStaleSeconds: this.config.jwksMaxStaleSeconds,
60
61
  logger: opts.logger,
61
62
  });
62
63
  this.#httpPost = opts.httpPost ?? defaultPost;
@@ -114,9 +115,14 @@ export class AccessTokenValidator {
114
115
  catch (exc) {
115
116
  return failure(errors.INVALID_TOKEN, errMessage(exc));
116
117
  }
118
+ // A token without typ (or with "typ": null) is refused like one with the wrong typ: an ID token or
119
+ // another JWT the issuer signs without a typ must not pass as an access token. allowMissingTokenType
120
+ // accepts the missing typ only; a wrong one is still refused.
117
121
  const types = this.config.acceptedTokenTypes;
118
122
  const typ = header.typ;
119
- if (typ != null && types !== null && (typeof typ !== "string" || !types.includes(normaliseType(typ)))) {
123
+ if (types !== null &&
124
+ !(typ == null && this.config.allowMissingTokenType) &&
125
+ (typeof typ !== "string" || !types.includes(normaliseType(typ)))) {
120
126
  return failure(errors.INVALID_TOKEN_TYPE, "token is not a JWT access token");
121
127
  }
122
128
  if (claims.iss !== this.config.issuer) {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * WWW-Authenticate parsing (RFC 9110 section 11.6.1) by the rule every port shares (vectors/README.md, "The
3
+ * WWW-Authenticate rule"; cases in vectors/www-authenticate.json). Anything that does not follow the grammar
4
+ * makes the whole value malformed, so a caller that acts on a challenge fails closed.
5
+ * @internal
6
+ */
7
+ export {};
@@ -0,0 +1,114 @@
1
+ /**
2
+ * WWW-Authenticate parsing (RFC 9110 section 11.6.1) by the rule every port shares (vectors/README.md, "The
3
+ * WWW-Authenticate rule"; cases in vectors/www-authenticate.json). Anything that does not follow the grammar
4
+ * makes the whole value malformed, so a caller that acts on a challenge fails closed.
5
+ * @internal
6
+ */
7
+ const TCHAR = /[!#$%&'*+\-.^_`|~0-9A-Za-z]/;
8
+ const TOKEN68 = /[\-._~+/0-9A-Za-z]/;
9
+ function ows(s, i) {
10
+ while (i < s.length && (s[i] === " " || s[i] === "\t"))
11
+ i++;
12
+ return i;
13
+ }
14
+ function token(s, i) {
15
+ while (i < s.length && TCHAR.test(s[i]))
16
+ i++;
17
+ return i;
18
+ }
19
+ function token68(s, i) {
20
+ const start = i;
21
+ while (i < s.length && TOKEN68.test(s[i]))
22
+ i++;
23
+ if (i === start)
24
+ return undefined;
25
+ while (s[i] === "=")
26
+ i++;
27
+ const k = ows(s, i);
28
+ return k === s.length || s[k] === "," ? i : undefined;
29
+ }
30
+ function qdtext(c) {
31
+ return c === 0x09 || c === 0x20 || c === 0x21 || (c >= 0x23 && c <= 0x5b) || (c >= 0x5d && c <= 0x7e) || c >= 0x80;
32
+ }
33
+ function quotedPairChar(c) {
34
+ return c === 0x09 || (c >= 0x20 && c <= 0x7e) || c >= 0x80;
35
+ }
36
+ /** A token or quoted-string at `i`: [value, index after it], or undefined. */
37
+ function value(s, i) {
38
+ if (s[i] === '"') {
39
+ let out = "";
40
+ for (i++; i < s.length;) {
41
+ const c = s.charCodeAt(i);
42
+ if (c === 0x22)
43
+ return [out, i + 1];
44
+ if (c === 0x5c) {
45
+ if (i + 1 >= s.length || !quotedPairChar(s.charCodeAt(i + 1)))
46
+ return undefined;
47
+ out += s[i + 1];
48
+ i += 2;
49
+ }
50
+ else if (qdtext(c)) {
51
+ out += s[i];
52
+ i++;
53
+ }
54
+ else {
55
+ return undefined;
56
+ }
57
+ }
58
+ return undefined;
59
+ }
60
+ const k = token(s, i);
61
+ return k > i ? [s.slice(i, k), k] : undefined;
62
+ }
63
+ /**
64
+ * Every field value of a WWW-Authenticate header, combined as one list (RFC 9110 section 5.3), as
65
+ * challenges; undefined when the combined value is malformed.
66
+ * @internal
67
+ */
68
+ export function parseWwwAuthenticate(values) {
69
+ const s = values.join(", ");
70
+ const out = [];
71
+ let i = 0;
72
+ for (;;) {
73
+ while (i < s.length && (s[i] === " " || s[i] === "\t" || s[i] === ","))
74
+ i++;
75
+ if (i >= s.length)
76
+ return out;
77
+ const start = i;
78
+ i = token(s, i);
79
+ if (i === start)
80
+ return undefined;
81
+ const name = s.slice(start, i).toLowerCase();
82
+ const j = ows(s, i);
83
+ if (s[j] === "=") {
84
+ if (out.length === 0)
85
+ return undefined;
86
+ const got = value(s, ows(s, j + 1));
87
+ if (got === undefined)
88
+ return undefined;
89
+ const params = out[out.length - 1].params;
90
+ if (params.has(name))
91
+ return undefined;
92
+ params.set(name, got[0]);
93
+ i = got[1];
94
+ }
95
+ else {
96
+ out.push({ scheme: name, params: new Map() });
97
+ const k = j > i ? token68(s, j) : undefined;
98
+ if (k === undefined) {
99
+ i = j; // its first auth-param (if any) follows without a comma
100
+ continue;
101
+ }
102
+ i = k;
103
+ }
104
+ i = ows(s, i);
105
+ if (i < s.length && s[i] !== ",")
106
+ return undefined;
107
+ }
108
+ }
109
+ /** A DPoP challenge whose error is exactly "use_dpop_nonce" (RFC 9449 section 9). @internal */
110
+ export function isDpopNonceChallenge(values) {
111
+ const challenges = parseWwwAuthenticate(values);
112
+ return challenges !== undefined &&
113
+ challenges.some((c) => c.scheme === "dpop" && c.params.get("error") === "use_dpop_nonce");
114
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@idpartners/client-attestation-sdk",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Client-side builder SDK for OAuth Attestation-Based Client Authentication",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -50,6 +50,7 @@
50
50
  "@types/node": "^20.0.0",
51
51
  "@vitest/coverage-v8": "^4.1.11",
52
52
  "esbuild": "^0.28.1",
53
+ "fast-check": "4.10.2",
53
54
  "tsx": "^4.23.15",
54
55
  "typescript": "^7.0.2",
55
56
  "vitest": "^4.1.11",