@idpartners/client-attestation-sdk 0.2.0 → 1.0.1

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.
@@ -3,10 +3,8 @@
3
3
  * bootstrapping ({@link AttestationIssuanceClient}). The default {@link SpireWorkloadApiSvidSource}
4
4
  * fetches it from a real SPIRE agent; the others cover dev and tests.
5
5
  */
6
- import { execFile } from "node:child_process";
7
- import { promisify } from "node:util";
8
6
  import { jsonOk, request } from "./http.js";
9
- const execFileAsync = promisify(execFile);
7
+ import { importNodeBuiltin } from "./runtime.js";
10
8
  /** Per-call timeout for {@link HttpSvidSource}. */
11
9
  const HTTP_SVID_TIMEOUT_MS = 20_000;
12
10
  /** A fixed, pre-fetched SVID — for tests, or when the caller already holds one. */
@@ -19,7 +17,15 @@ export class StaticSvidSource {
19
17
  return this.svid;
20
18
  }
21
19
  }
22
- /** GET a URL returning `{"svid":"<jwt>"}` — e.g. the demo harness `/dev/svid` endpoint. */
20
+ /**
21
+ * GET a URL returning `{"svid":"<jwt>"}` — e.g. the demo harness `/dev/svid` endpoint.
22
+ *
23
+ * For development and testing only. It trusts whatever the URL returns and the endpoint has no caller
24
+ * authentication, so anyone who can reach it can obtain the workload's SVID. In production use a SPIRE,
25
+ * file or cloud source.
26
+ *
27
+ * @experimental See docs/api.md: a development and test source with no caller authentication.
28
+ */
23
29
  export class HttpSvidSource {
24
30
  url;
25
31
  constructor(url) {
@@ -37,6 +43,12 @@ export class HttpSvidSource {
37
43
  * The default source: fetch a JWT-SVID from a real SPIRE agent's Workload API via the `spire-agent`
38
44
  * CLI. Swap for a native Workload API client by implementing {@link SvidSource} if you prefer no
39
45
  * subprocess.
46
+ *
47
+ * Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
48
+ * a `ClientAttestationError` on a runtime without it.
49
+ *
50
+ * @experimental See docs/api.md: it shells out to the spire-agent CLI, and its constructor will change
51
+ * when it moves to the Workload API socket.
40
52
  */
41
53
  export class SpireWorkloadApiSvidSource {
42
54
  socketPath;
@@ -46,9 +58,10 @@ export class SpireWorkloadApiSvidSource {
46
58
  this.spireAgentBin = spireAgentBin;
47
59
  }
48
60
  async fetch(audience) {
49
- const { stdout } = await execFileAsync(this.spireAgentBin, [
50
- "api", "fetch", "jwt", "-audience", audience, "-socketPath", this.socketPath,
51
- ]);
61
+ const { execFile } = await importNodeBuiltin("child_process", "SpireWorkloadApiSvidSource");
62
+ const stdout = await new Promise((resolve, reject) => {
63
+ execFile(this.spireAgentBin, ["api", "fetch", "jwt", "-audience", audience, "-socketPath", this.socketPath], (err, out) => (err ? reject(err) : resolve(String(out))));
64
+ });
52
65
  for (let line of stdout.split("\n")) {
53
66
  line = line.trim();
54
67
  if (line.startsWith("token(")) {
@@ -1,12 +1,14 @@
1
+ import { type AttesterMetadata, type AttesterProfile } from "./attester.js";
2
+ import { type Logger } from "./logging.js";
1
3
  import type { SvidSource } from "./svid-source.js";
2
4
  export declare const ATTESTATION_HEADER = "OAuth-Client-Attestation";
3
5
  export declare const POP_HEADER = "OAuth-Client-Attestation-PoP";
4
- export declare const WELL_KNOWN_PATH = "/.well-known/client-attester";
5
6
  /** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
6
7
  export declare const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
7
8
  export { ClientAttestationError, TransportError } from "./errors.js";
8
- /** The attester's `/.well-known/client-attester` discovery document. */
9
- interface AttesterDiscovery {
9
+ /** The attester's discovery document (PingFederate: `/.well-known/client-attester`). */
10
+ export interface AttesterDiscovery {
11
+ [member: string]: unknown;
10
12
  evidence_audience?: string;
11
13
  attestation_endpoint?: string;
12
14
  challenge_endpoint?: string;
@@ -15,6 +17,21 @@ interface AttesterDiscovery {
15
17
  }
16
18
  /** Options for {@link ClientAttestation}. */
17
19
  export interface ClientAttestationOptions {
20
+ /**
21
+ * The attester's host, when the options object is the only argument. The positional argument wins
22
+ * when both are given.
23
+ */
24
+ attesterUrl?: string;
25
+ /**
26
+ * The attester profile that discovers the attester and mints the attestation. Default
27
+ * {@link PingFederateProfile}, the behaviour of every earlier release. See docs/attester-profiles.md.
28
+ */
29
+ profile?: AttesterProfile;
30
+ /**
31
+ * Static attester metadata (evidence audience, attestation and token endpoints, optionally challenge
32
+ * endpoint and PoP audience). When set, no discovery request is made; the profile still mints.
33
+ */
34
+ metadata?: AttesterMetadata;
18
35
  /**
19
36
  * How to get the workload's platform identity. Leave unset to auto-detect the runtime (env token,
20
37
  * projected token file, SPIRE socket, or GCP metadata). Pass an {@link SvidSource} to override.
@@ -63,21 +80,32 @@ export interface ClientAttestationOptions {
63
80
  */
64
81
  requireChallenge?: boolean;
65
82
  /**
66
- * Allow plain `http` for the PingFederate host and the endpoints the attester advertises (default
83
+ * Allow plain `http` for the attester URL and the endpoints the attester advertises (default
67
84
  * `false`). Loopback hosts (`localhost`, `127.0.0.1`, `::1`) are always allowed.
68
85
  */
69
86
  allowInsecure?: boolean;
87
+ /**
88
+ * Where to send log events (see docs/logging.md). Any object with `debug`, `info`, `warn` and
89
+ * `error(msg, attrs)` works, `console` included. Unset, nothing is logged. Tokens, proofs,
90
+ * attestations, SVIDs and key material are never logged.
91
+ */
92
+ logger?: Logger;
70
93
  }
71
94
  /**
72
- * Gets and caches an access token from a PingFederate host using client attestation.
95
+ * Gets and caches an access token using client attestation.
96
+ *
97
+ * `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
98
+ * discovery) or a custom `opts.profile`.
73
99
  *
74
- * @param pfHost the PingFederate host — `"pf.example.com"`, or a full base URL
75
- * `"https://pf.example.com"`. Plain hostnames default to https.
100
+ * @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
101
+ * `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
102
+ * is given.
76
103
  */
77
104
  export declare class ClientAttestation {
78
105
  #private;
106
+ /** The normalised attester URL, or `""` when the client was built from static metadata alone. */
79
107
  readonly baseUrl: string;
80
- constructor(pfHost: string, opts?: ClientAttestationOptions);
108
+ constructor(attesterUrl?: string | ClientAttestationOptions, opts?: ClientAttestationOptions);
81
109
  /** Return a valid access token, minting a new one only when the cached one is near expiry. */
82
110
  token(opts?: {
83
111
  forceRefresh?: boolean;
@@ -106,15 +134,14 @@ export declare class ClientAttestation {
106
134
  * as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
107
135
  * a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
108
136
  * unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
137
+ * Redirects are returned without following them, even if the input or init requests redirect following.
109
138
  */
110
139
  fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
111
- /** The attester's `/.well-known/client-attester` document (fetched once, then cached). */
140
+ /**
141
+ * The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
142
+ * then cached. With static `metadata`, the fields that were given, in the document's snake_case.
143
+ */
112
144
  discovery(): Promise<AttesterDiscovery>;
145
+ /** The attester's endpoints: the static `metadata`, or what the profile discovered (once, then cached). */
146
+ attesterMetadata(): Promise<AttesterMetadata>;
113
147
  }
114
- /**
115
- * Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
116
- * bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
117
- */
118
- export declare function canonicalJson(value: unknown): string;
119
- export declare function normalizeBaseUrl(pfHost: string): string;
120
- export declare function jwtSub(compactJwt: string): string;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * One call to get an access token with client attestation.
3
3
  *
4
- * Point it at a PingFederate host and call `.token()`. It runs the whole flow:
4
+ * Point it at an attester (PingFederate by default) and call `.token()`. It runs the whole flow:
5
5
  *
6
6
  * discover -> get platform identity (evidence) -> get a challenge -> mint the attestation ->
7
7
  * call the token endpoint with the attestation headers -> cache the access token
@@ -19,30 +19,43 @@
19
19
  * // or build the headers for any other client:
20
20
  * const headers = await ca.requestHeaders("GET", "https://api.example.com/things");
21
21
  */
22
- import { randomUUID } from "node:crypto";
22
+ import { base64url } from "jose";
23
23
  import { DpopProofBuilder } from "./builders.js";
24
24
  import { ClientAttestationCredential, DPOP_HEADER } from "./credential.js";
25
- import { ClientAttestationError } from "./errors.js";
25
+ import { ClientAttestationError, TransportError } from "./errors.js";
26
26
  import { autoDetectEvidence } from "./evidence.js";
27
- import { INSTANCE_PROOF_TYP } from "./issuance.js";
27
+ import { MintRequest, PingFederateProfile, metadataDocument, } from "./attester.js";
28
28
  import { jsonOk, request } from "./http.js";
29
- import { SigningKeyPair, signCompact } from "./signing-key-pair.js";
29
+ import { emit, failureReason, hostOf, NOOP_LOGGER, urlParts } from "./logging.js";
30
+ import { utf8Decode } from "./runtime.js";
31
+ import { SigningKeyPair } from "./signing-key-pair.js";
30
32
  export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
31
33
  export const POP_HEADER = "OAuth-Client-Attestation-PoP";
32
- export const WELL_KNOWN_PATH = "/.well-known/client-attester";
33
34
  /** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
34
35
  export const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
35
36
  export { ClientAttestationError, TransportError } from "./errors.js";
36
37
  const USE_DPOP_NONCE = "use_dpop_nonce";
37
38
  const DPOP_NONCE_HEADER = "DPoP-Nonce";
39
+ /** What the https check calls the URL a profile's call goes to, by step. */
40
+ const STEP_LABELS = {
41
+ discover: "attesterUrl",
42
+ mint: "attestation_endpoint",
43
+ challenge: "challenge_endpoint",
44
+ };
38
45
  /**
39
- * Gets and caches an access token from a PingFederate host using client attestation.
46
+ * Gets and caches an access token using client attestation.
40
47
  *
41
- * @param pfHost the PingFederate host — `"pf.example.com"`, or a full base URL
42
- * `"https://pf.example.com"`. Plain hostnames default to https.
48
+ * `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
49
+ * discovery) or a custom `opts.profile`.
50
+ *
51
+ * @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
52
+ * `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
53
+ * is given.
43
54
  */
44
55
  export class ClientAttestation {
56
+ /** The normalised attester URL, or `""` when the client was built from static metadata alone. */
45
57
  baseUrl;
58
+ #profile;
46
59
  #evidence;
47
60
  #authorizationDetails;
48
61
  #operator;
@@ -53,14 +66,21 @@ export class ClientAttestation {
53
66
  #popMethod;
54
67
  #requireChallenge;
55
68
  #allowInsecure;
69
+ #logger;
56
70
  /** The token, its type and its bound key: always replaced together, never one without the others. */
57
71
  #state = null;
58
- #discovery = null;
72
+ #metadata;
59
73
  #refreshing = null;
60
74
  /** The last `DPoP-Nonce` each origin returned. */
61
75
  #nonces = new Map();
62
- constructor(pfHost, opts = {}) {
63
- this.baseUrl = normalizeBaseUrl(pfHost);
76
+ constructor(attesterUrl, opts = {}) {
77
+ if (typeof attesterUrl === "object" && attesterUrl !== null) {
78
+ opts = attesterUrl;
79
+ attesterUrl = undefined;
80
+ }
81
+ const url = attesterUrl ?? opts.attesterUrl;
82
+ this.#profile = opts.profile ?? new PingFederateProfile();
83
+ this.#metadata = opts.metadata ?? null;
64
84
  this.#evidence = opts.evidence;
65
85
  this.#authorizationDetails = opts.authorizationDetails;
66
86
  this.#operator = opts.operator && Object.keys(opts.operator).length > 0 ? opts.operator : undefined;
@@ -71,7 +91,14 @@ export class ClientAttestation {
71
91
  this.#popMethod = opts.popMethod ?? "attestation_pop_jwt";
72
92
  this.#requireChallenge = opts.requireChallenge ?? true;
73
93
  this.#allowInsecure = opts.allowInsecure ?? false;
74
- this.#checkSecure(this.baseUrl, "pfHost");
94
+ this.#logger = opts.logger ?? NOOP_LOGGER;
95
+ if (url === undefined && (opts.metadata || opts.profile)) {
96
+ this.baseUrl = "";
97
+ }
98
+ else {
99
+ this.baseUrl = normalizeBaseUrl(url);
100
+ this.#checkSecure(this.baseUrl, "attesterUrl");
101
+ }
75
102
  }
76
103
  // ── public API ──────────────────────────────────────────────────────────────────────────────────
77
104
  /** Return a valid access token, minting a new one only when the cached one is near expiry. */
@@ -113,6 +140,7 @@ export class ClientAttestation {
113
140
  * as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
114
141
  * a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
115
142
  * unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
143
+ * Redirects are returned without following them, even if the input or init requests redirect following.
116
144
  */
117
145
  async fetch(input, init = {}) {
118
146
  const isRequest = typeof Request !== "undefined" && input instanceof Request;
@@ -127,7 +155,7 @@ export class ClientAttestation {
127
155
  dpopBound = DPOP_HEADER in auth;
128
156
  for (const [name, value] of Object.entries(auth))
129
157
  headers.set(name, value);
130
- return fetch(nextInput(), { ...init, headers });
158
+ return fetch(nextInput(), { ...init, headers, redirect: "manual" });
131
159
  };
132
160
  const first = await send();
133
161
  if (!dpopBound)
@@ -137,24 +165,52 @@ export class ClientAttestation {
137
165
  this.#nonces.set(originOf(url), nonce);
138
166
  if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
139
167
  return first;
168
+ emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(url), status: first.status, at: "resource" });
140
169
  await first.body?.cancel().catch(() => undefined);
141
170
  return send();
142
171
  }
143
- /** The attester's `/.well-known/client-attester` document (fetched once, then cached). */
172
+ /**
173
+ * The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
174
+ * then cached. With static `metadata`, the fields that were given, in the document's snake_case.
175
+ */
144
176
  async discovery() {
145
- if (this.#discovery === null) {
146
- this.#discovery = await this.#getJson("GET", this.baseUrl + WELL_KNOWN_PATH);
177
+ return metadataDocument(await this.attesterMetadata());
178
+ }
179
+ /** The attester's endpoints: the static `metadata`, or what the profile discovered (once, then cached). */
180
+ async attesterMetadata() {
181
+ const host = this.baseUrl ? hostOf(this.baseUrl) : undefined;
182
+ if (this.#metadata === null) {
183
+ this.#metadata = await this.#profile.discover(this.#transport(), this.baseUrl || undefined);
184
+ emit(this.#logger, "debug", "discovery.fetched", { host });
147
185
  }
148
- return this.#discovery;
186
+ else {
187
+ emit(this.#logger, "debug", "discovery.cached", { host });
188
+ }
189
+ return this.#metadata;
190
+ }
191
+ /** The transport handed to the profile: this client's https rule, timeout and logging. */
192
+ #transport() {
193
+ return {
194
+ requestJson: (step, method, url, body) => {
195
+ this.#checkSecure(url, STEP_LABELS[step] ?? `${step} URL`);
196
+ return this.#getJson(step, method, url, body);
197
+ },
198
+ };
149
199
  }
150
200
  // ── the flow ────────────────────────────────────────────────────────────────────────────────────
151
201
  /** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
152
202
  async #current(opts) {
153
203
  const state = this.#state;
154
204
  if (!opts.forceRefresh && state && Date.now() < state.expiresAt) {
205
+ emit(this.#logger, "debug", "token.cache_hit", {
206
+ expires_in: Math.max(0, Math.round((state.expiresAt - Date.now()) / 1000)),
207
+ });
155
208
  return state;
156
209
  }
157
210
  if (!this.#refreshing) {
211
+ emit(this.#logger, "debug", "token.refreshing", {
212
+ reason: opts.forceRefresh ? "forced" : state ? "expired" : "initial",
213
+ });
158
214
  this.#refreshing = this.#refresh().finally(() => {
159
215
  this.#refreshing = null;
160
216
  });
@@ -174,33 +230,58 @@ export class ClientAttestation {
174
230
  return state;
175
231
  }
176
232
  async #runFlow() {
177
- const doc = await this.discovery();
178
- const audience = doc.evidence_audience;
233
+ const meta = await this.attesterMetadata();
234
+ const audience = meta.evidenceAudience;
179
235
  if (!audience) {
180
236
  throw new ClientAttestationError("attester discovery document has no evidence_audience");
181
237
  }
182
238
  const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
183
239
  const svid = await evidenceSource.fetch(audience);
240
+ emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
184
241
  const instanceKey = await SigningKeyPair.generate(this.#algorithm);
185
- const challenge = await this.#fetchChallenge(doc);
186
- const attestation = await this.#mint(doc, instanceKey, svid, challenge);
187
- // token_endpoint is advertised by current attesters; fall back to the standard PF path so an older
188
- // discovery document still works from the host alone.
189
- const tokenEndpoint = doc.token_endpoint || this.baseUrl + "/as/token.oauth2";
242
+ const challenge = await this.#fetchChallenge(meta.challengeEndpoint);
243
+ const { attestation } = await this.#profile.mint(this.#transport(), meta, new MintRequest({
244
+ instanceKey,
245
+ evidence: svid,
246
+ challenge,
247
+ authorizationDetails: this.#authorizationDetails,
248
+ operator: this.#operator,
249
+ }));
250
+ // The profile resolves the token endpoint (PingFederate: the advertised one, else the standard path
251
+ // on the attester host).
252
+ const tokenEndpoint = meta.tokenEndpoint;
253
+ if (!tokenEndpoint)
254
+ throw new ClientAttestationError("attester metadata has no token_endpoint");
190
255
  this.#checkSecure(tokenEndpoint, "token_endpoint");
191
256
  const clientId = jwtSub(attestation);
257
+ emit(this.#logger, "debug", "attestation.minted", {
258
+ host: meta.attestationEndpoint ? hostOf(meta.attestationEndpoint) : undefined,
259
+ client_id: clientId,
260
+ alg: this.#algorithm,
261
+ });
192
262
  // The PoP audience must be PF's configured issuer, which the attester advertises as pop_audience.
193
263
  // Falling back to the token endpoint URL only works when the dialed URL matches PF's issuer (no
194
264
  // proxy in between) — so prefer the advertised value.
195
- const popAudience = doc.pop_audience || tokenEndpoint;
265
+ const popAudience = meta.popAudience || tokenEndpoint;
196
266
  // See `challengeAtToken`: some attesters consume the challenge the moment the mint's instance
197
267
  // proof uses it, and reject a token request that presents the same value a second time. Against
198
268
  // one of those, only the mint gets the challenge.
199
269
  const tokenChallenge = this.#challengeAtToken ? challenge : undefined;
200
270
  const credential = new ClientAttestationCredential(attestation, instanceKey);
271
+ const obtained = (response) => {
272
+ const expiresIn = Number(response.expires_in);
273
+ emit(this.#logger, "info", "token.obtained", {
274
+ host: hostOf(tokenEndpoint),
275
+ client_id: clientId,
276
+ 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,
278
+ pop_method: this.#popMethod,
279
+ });
280
+ return { response, key: instanceKey };
281
+ };
201
282
  if (this.#popMethod !== "dpop_combined") {
202
283
  const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
203
- return { response: await this.#callTokenEndpoint(tokenEndpoint, headers), key: instanceKey };
284
+ return obtained(this.#tokenResponse(tokenEndpoint, await this.#postToken(tokenEndpoint, headers)));
204
285
  }
205
286
  // DPoP combined: the attester challenge rides as the DPoP nonce. If the AS demands its own nonce
206
287
  // (400 use_dpop_nonce + DPoP-Nonce), retry once with that — the server's demand wins.
@@ -209,12 +290,12 @@ export class ClientAttestation {
209
290
  if (serverNonce)
210
291
  this.#nonces.set(originOf(tokenEndpoint), serverNonce);
211
292
  if (res.status === 400 && serverNonce && jsonError(res.body) === USE_DPOP_NONCE) {
293
+ emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
212
294
  res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
213
295
  }
214
- return { response: tokenResponse(res), key: instanceKey };
296
+ return obtained(this.#tokenResponse(tokenEndpoint, res));
215
297
  }
216
- async #fetchChallenge(doc) {
217
- const endpoint = doc.challenge_endpoint;
298
+ async #fetchChallenge(endpoint) {
218
299
  if (!endpoint)
219
300
  return undefined;
220
301
  this.#checkSecure(endpoint, "challenge_endpoint");
@@ -222,13 +303,15 @@ export class ClientAttestation {
222
303
  // closed unless the caller opted out with requireChallenge: false.
223
304
  let body;
224
305
  try {
225
- body = await this.#getJson("POST", endpoint);
306
+ body = await this.#getJson("challenge", "POST", endpoint);
226
307
  }
227
308
  catch (err) {
228
309
  if (!(err instanceof ClientAttestationError))
229
310
  throw err;
230
- if (!this.#requireChallenge)
311
+ if (!this.#requireChallenge) {
312
+ emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
231
313
  return undefined;
314
+ }
232
315
  throw new ClientAttestationError(`could not fetch the attestation challenge from ${endpoint}: ${err.message}`, {
233
316
  status: err.status,
234
317
  body: err.body,
@@ -236,46 +319,29 @@ export class ClientAttestation {
236
319
  });
237
320
  }
238
321
  const challenge = body.attestation_challenge;
239
- if (typeof challenge === "string" && challenge)
322
+ if (typeof challenge === "string" && challenge) {
323
+ emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint) });
240
324
  return challenge;
241
- if (!this.#requireChallenge)
325
+ }
326
+ if (!this.#requireChallenge) {
327
+ emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
242
328
  return undefined;
329
+ }
243
330
  throw new ClientAttestationError(`challenge endpoint ${endpoint} returned no attestation_challenge`);
244
331
  }
245
- async #mint(doc, instanceKey, svid, challenge) {
246
- if (!doc.attestation_endpoint) {
247
- throw new ClientAttestationError("attester discovery document has no attestation_endpoint");
332
+ /** The parsed token response; a failure is logged as `http.failed` before it is thrown. */
333
+ #tokenResponse(endpoint, res) {
334
+ try {
335
+ return tokenResponse(res);
248
336
  }
249
- this.#checkSecure(doc.attestation_endpoint, "attestation_endpoint");
250
- const now = Math.floor(Date.now() / 1000);
251
- const proofClaims = {
252
- aud: doc.evidence_audience,
253
- jti: randomUUID(),
254
- iat: now,
255
- };
256
- if (challenge)
257
- proofClaims.challenge = challenge;
258
- const proof = await signCompact(proofClaims, instanceKey, INSTANCE_PROOF_TYP, false);
259
- // Evidence-first: no client_id. The attester maps the identity to a client.
260
- const body = {
261
- instance_key: instanceKey.publicJwk(),
262
- svid,
263
- proof,
264
- };
265
- if (this.#authorizationDetails)
266
- body.authorization_details = this.#authorizationDetails;
267
- if (this.#operator)
268
- body.operator = this.#operator;
269
- const response = await this.#getJson("POST", doc.attestation_endpoint, body);
270
- if (!response.attestation) {
271
- throw new ClientAttestationError("attester response contained no attestation", {
272
- body: JSON.stringify(response),
273
- });
337
+ catch (err) {
338
+ const reason = err instanceof TransportError ? failureReason(err) : res.ok ? "malformed" : "status";
339
+ this.#failed("token", "POST", endpoint, reason, res.status);
340
+ throw err;
274
341
  }
275
- return response.attestation;
276
342
  }
277
- async #callTokenEndpoint(endpoint, authHeaders) {
278
- return tokenResponse(await this.#postToken(endpoint, authHeaders));
343
+ #failed(step, method, url, reason, status) {
344
+ emit(this.#logger, "warn", "http.failed", { step, method, ...urlParts(url), status, reason });
279
345
  }
280
346
  #postToken(endpoint, authHeaders) {
281
347
  const params = new URLSearchParams({ grant_type: "client_credentials" });
@@ -284,7 +350,7 @@ export class ClientAttestation {
284
350
  if (this.#operator)
285
351
  params.set("operator", canonicalJson(this.#operator));
286
352
  const form = params.toString();
287
- return this.#send(endpoint, {
353
+ return this.#send("token", endpoint, {
288
354
  method: "POST",
289
355
  headers: {
290
356
  "Content-Type": "application/x-www-form-urlencoded",
@@ -309,18 +375,35 @@ export class ClientAttestation {
309
375
  return;
310
376
  throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
311
377
  }
312
- async #getJson(method, url, jsonBody) {
378
+ async #getJson(step, method, url, jsonBody) {
313
379
  const headers = { Accept: "application/json" };
314
380
  let body;
315
381
  if (jsonBody !== undefined) {
316
382
  headers["Content-Type"] = "application/json";
317
383
  body = JSON.stringify(jsonBody);
318
384
  }
319
- return jsonOk(await this.#send(url, { method, headers, body }), { allowEmpty: true });
385
+ const res = await this.#send(step, url, { method, headers, body });
386
+ try {
387
+ return jsonOk(res, { allowEmpty: true });
388
+ }
389
+ catch (err) {
390
+ this.#failed(step, method, url, failureReason(err), res.status);
391
+ throw err;
392
+ }
320
393
  }
321
- /** Every call the flow makes goes through the shared transport, with this client's timeout. */
322
- #send(url, opts) {
323
- return request(url, { ...opts, timeoutMs: this.#timeoutMs });
394
+ /**
395
+ * Every call the flow makes goes through the shared transport, with this client's timeout. A call that
396
+ * gets no usable response is logged as `http.failed`.
397
+ */
398
+ async #send(step, url, opts) {
399
+ try {
400
+ return await request(url, { ...opts, timeoutMs: this.#timeoutMs });
401
+ }
402
+ catch (err) {
403
+ const status = err instanceof ClientAttestationError ? err.status : undefined;
404
+ this.#failed(step, opts.method ?? "GET", url, failureReason(err), status);
405
+ throw err;
406
+ }
324
407
  }
325
408
  }
326
409
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
@@ -385,6 +468,8 @@ async function isNonceChallenge(res) {
385
468
  /**
386
469
  * Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
387
470
  * bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
471
+ *
472
+ * @internal
388
473
  */
389
474
  export function canonicalJson(value) {
390
475
  return JSON.stringify(sortKeys(value)).replace(/[\u0080-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
@@ -398,21 +483,23 @@ function sortKeys(value) {
398
483
  }
399
484
  return value;
400
485
  }
401
- export function normalizeBaseUrl(pfHost) {
402
- let host = (pfHost ?? "").trim().replace(/\/+$/, "");
486
+ /** @internal */
487
+ export function normalizeBaseUrl(attesterUrl) {
488
+ let host = (attesterUrl ?? "").trim().replace(/\/+$/, "");
403
489
  if (!host) {
404
- throw new Error("pfHost is required");
490
+ throw new Error("attesterUrl is required");
405
491
  }
406
492
  if (!host.includes("://")) {
407
493
  host = "https://" + host;
408
494
  }
409
495
  return host;
410
496
  }
497
+ /** @internal */
411
498
  export function jwtSub(compactJwt) {
412
499
  const payload = compactJwt.split(".")[1] ?? "";
413
500
  let claims;
414
501
  try {
415
- claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
502
+ claims = JSON.parse(utf8Decode(base64url.decode(payload)));
416
503
  }
417
504
  catch {
418
505
  throw new ClientAttestationError("minted attestation is not a well-formed JWT");
@@ -2,7 +2,7 @@
2
2
  * Validator configuration.
3
3
  */
4
4
  import type { JSONWebKeySet } from "jose";
5
- /** Default accepted JWS algorithms: EC / RSA / RSA-PSS at 256/384/512. */
5
+ /** Default accepted JWS algorithms: EC / RSA / RSA-PSS at 256/384/512. Frozen, so it can't be widened. */
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[];
@@ -27,6 +27,10 @@ export interface IntrospectionConfig {
27
27
  * else. `acceptedTokenTypes` is checked against the JWT `typ` header when the token carries one, so an ID
28
28
  * token can't stand in for an access token. Pass `null` to turn that check off for an issuer that stamps
29
29
  * something other than `at+jwt`.
30
+ *
31
+ * An explicitly empty `acceptedAlgorithms` or `acceptedTokenTypes` accepts nothing, and `leewaySeconds: 0`
32
+ * allows no clock skew: only an omitted field takes the default. `none` and the `HS*` algorithms are
33
+ * refused even when listed (see validation/SEMANTICS.md).
30
34
  */
31
35
  export interface ValidatorConfig {
32
36
  issuer: string;
@@ -55,10 +59,3 @@ export interface ResolvedConfig {
55
59
  /** Normalised with {@link normaliseType}; `null` when the check is off. */
56
60
  acceptedTokenTypes: string[] | null;
57
61
  }
58
- /**
59
- * Compare JOSE `typ` values the way RFC 7515 section 4.1.9 says to: case-insensitively, with an optional
60
- * `application/` prefix.
61
- */
62
- export declare function normaliseType(value: string): string;
63
- /** Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`). */
64
- export declare function resolveConfig(config: ValidatorConfig): ResolvedConfig;
@@ -1,5 +1,5 @@
1
- /** Default accepted JWS algorithms: EC / RSA / RSA-PSS at 256/384/512. */
2
- export const DEFAULT_ALGORITHMS = [
1
+ /** Default accepted JWS algorithms: EC / RSA / RSA-PSS at 256/384/512. Frozen, so it can't be widened. */
2
+ export const DEFAULT_ALGORITHMS = Object.freeze([
3
3
  "ES256",
4
4
  "ES384",
5
5
  "ES512",
@@ -9,18 +9,35 @@ export const DEFAULT_ALGORITHMS = [
9
9
  "PS256",
10
10
  "PS384",
11
11
  "PS512",
12
- ];
12
+ ]);
13
13
  /** RFC 9068 section 2.1: a JWT access token's `typ` is `at+jwt` (the `application/` prefix is optional). */
14
- export const DEFAULT_TOKEN_TYPES = ["at+jwt"];
14
+ export const DEFAULT_TOKEN_TYPES = Object.freeze(["at+jwt"]);
15
+ /**
16
+ * `none` (in any case) and the HMAC algorithms are refused whatever `acceptedAlgorithms` says: an unsigned
17
+ * token proves nothing, and an HMAC "signature" checked against an issuer's public key is forgeable by
18
+ * anyone who has that key.
19
+ *
20
+ * @internal
21
+ */
22
+ export function isForbiddenAlgorithm(alg) {
23
+ const lowered = alg.toLowerCase();
24
+ return lowered === "none" || lowered.startsWith("hs");
25
+ }
15
26
  /**
16
27
  * Compare JOSE `typ` values the way RFC 7515 section 4.1.9 says to: case-insensitively, with an optional
17
28
  * `application/` prefix.
29
+ *
30
+ * @internal
18
31
  */
19
32
  export function normaliseType(value) {
20
33
  const lower = value.toLowerCase();
21
34
  return lower.startsWith("application/") ? lower.slice("application/".length) : lower;
22
35
  }
23
- /** Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`). */
36
+ /**
37
+ * Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`).
38
+ *
39
+ * @internal
40
+ */
24
41
  export function resolveConfig(config) {
25
42
  if (!config.issuer) {
26
43
  throw new Error("issuer is required");