@idpartners/client-attestation-sdk 0.2.0 → 1.0.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.
@@ -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;
@@ -108,13 +136,11 @@ export declare class ClientAttestation {
108
136
  * unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
109
137
  */
110
138
  fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
111
- /** The attester's `/.well-known/client-attester` document (fetched once, then cached). */
139
+ /**
140
+ * The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
141
+ * then cached. With static `metadata`, the fields that were given, in the document's snake_case.
142
+ */
112
143
  discovery(): Promise<AttesterDiscovery>;
144
+ /** The attester's endpoints: the static `metadata`, or what the profile discovered (once, then cached). */
145
+ attesterMetadata(): Promise<AttesterMetadata>;
113
146
  }
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. */
@@ -137,24 +164,52 @@ export class ClientAttestation {
137
164
  this.#nonces.set(originOf(url), nonce);
138
165
  if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
139
166
  return first;
167
+ emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(url), status: first.status, at: "resource" });
140
168
  await first.body?.cancel().catch(() => undefined);
141
169
  return send();
142
170
  }
143
- /** The attester's `/.well-known/client-attester` document (fetched once, then cached). */
171
+ /**
172
+ * The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
173
+ * then cached. With static `metadata`, the fields that were given, in the document's snake_case.
174
+ */
144
175
  async discovery() {
145
- if (this.#discovery === null) {
146
- this.#discovery = await this.#getJson("GET", this.baseUrl + WELL_KNOWN_PATH);
176
+ return metadataDocument(await this.attesterMetadata());
177
+ }
178
+ /** The attester's endpoints: the static `metadata`, or what the profile discovered (once, then cached). */
179
+ async attesterMetadata() {
180
+ const host = this.baseUrl ? hostOf(this.baseUrl) : undefined;
181
+ if (this.#metadata === null) {
182
+ this.#metadata = await this.#profile.discover(this.#transport(), this.baseUrl || undefined);
183
+ emit(this.#logger, "debug", "discovery.fetched", { host });
147
184
  }
148
- return this.#discovery;
185
+ else {
186
+ emit(this.#logger, "debug", "discovery.cached", { host });
187
+ }
188
+ return this.#metadata;
189
+ }
190
+ /** The transport handed to the profile: this client's https rule, timeout and logging. */
191
+ #transport() {
192
+ return {
193
+ requestJson: (step, method, url, body) => {
194
+ this.#checkSecure(url, STEP_LABELS[step] ?? `${step} URL`);
195
+ return this.#getJson(step, method, url, body);
196
+ },
197
+ };
149
198
  }
150
199
  // ── the flow ────────────────────────────────────────────────────────────────────────────────────
151
200
  /** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
152
201
  async #current(opts) {
153
202
  const state = this.#state;
154
203
  if (!opts.forceRefresh && state && Date.now() < state.expiresAt) {
204
+ emit(this.#logger, "debug", "token.cache_hit", {
205
+ expires_in: Math.max(0, Math.round((state.expiresAt - Date.now()) / 1000)),
206
+ });
155
207
  return state;
156
208
  }
157
209
  if (!this.#refreshing) {
210
+ emit(this.#logger, "debug", "token.refreshing", {
211
+ reason: opts.forceRefresh ? "forced" : state ? "expired" : "initial",
212
+ });
158
213
  this.#refreshing = this.#refresh().finally(() => {
159
214
  this.#refreshing = null;
160
215
  });
@@ -174,33 +229,58 @@ export class ClientAttestation {
174
229
  return state;
175
230
  }
176
231
  async #runFlow() {
177
- const doc = await this.discovery();
178
- const audience = doc.evidence_audience;
232
+ const meta = await this.attesterMetadata();
233
+ const audience = meta.evidenceAudience;
179
234
  if (!audience) {
180
235
  throw new ClientAttestationError("attester discovery document has no evidence_audience");
181
236
  }
182
237
  const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
183
238
  const svid = await evidenceSource.fetch(audience);
239
+ emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
184
240
  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";
241
+ const challenge = await this.#fetchChallenge(meta.challengeEndpoint);
242
+ const { attestation } = await this.#profile.mint(this.#transport(), meta, new MintRequest({
243
+ instanceKey,
244
+ evidence: svid,
245
+ challenge,
246
+ authorizationDetails: this.#authorizationDetails,
247
+ operator: this.#operator,
248
+ }));
249
+ // The profile resolves the token endpoint (PingFederate: the advertised one, else the standard path
250
+ // on the attester host).
251
+ const tokenEndpoint = meta.tokenEndpoint;
252
+ if (!tokenEndpoint)
253
+ throw new ClientAttestationError("attester metadata has no token_endpoint");
190
254
  this.#checkSecure(tokenEndpoint, "token_endpoint");
191
255
  const clientId = jwtSub(attestation);
256
+ emit(this.#logger, "debug", "attestation.minted", {
257
+ host: meta.attestationEndpoint ? hostOf(meta.attestationEndpoint) : undefined,
258
+ client_id: clientId,
259
+ alg: this.#algorithm,
260
+ });
192
261
  // The PoP audience must be PF's configured issuer, which the attester advertises as pop_audience.
193
262
  // Falling back to the token endpoint URL only works when the dialed URL matches PF's issuer (no
194
263
  // proxy in between) — so prefer the advertised value.
195
- const popAudience = doc.pop_audience || tokenEndpoint;
264
+ const popAudience = meta.popAudience || tokenEndpoint;
196
265
  // See `challengeAtToken`: some attesters consume the challenge the moment the mint's instance
197
266
  // proof uses it, and reject a token request that presents the same value a second time. Against
198
267
  // one of those, only the mint gets the challenge.
199
268
  const tokenChallenge = this.#challengeAtToken ? challenge : undefined;
200
269
  const credential = new ClientAttestationCredential(attestation, instanceKey);
270
+ const obtained = (response) => {
271
+ const expiresIn = Number(response.expires_in);
272
+ emit(this.#logger, "info", "token.obtained", {
273
+ host: hostOf(tokenEndpoint),
274
+ client_id: clientId,
275
+ token_type: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
276
+ expires_in: response.expires_in != null && Number.isFinite(expiresIn) ? expiresIn : undefined,
277
+ pop_method: this.#popMethod,
278
+ });
279
+ return { response, key: instanceKey };
280
+ };
201
281
  if (this.#popMethod !== "dpop_combined") {
202
282
  const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
203
- return { response: await this.#callTokenEndpoint(tokenEndpoint, headers), key: instanceKey };
283
+ return obtained(this.#tokenResponse(tokenEndpoint, await this.#postToken(tokenEndpoint, headers)));
204
284
  }
205
285
  // DPoP combined: the attester challenge rides as the DPoP nonce. If the AS demands its own nonce
206
286
  // (400 use_dpop_nonce + DPoP-Nonce), retry once with that — the server's demand wins.
@@ -209,12 +289,12 @@ export class ClientAttestation {
209
289
  if (serverNonce)
210
290
  this.#nonces.set(originOf(tokenEndpoint), serverNonce);
211
291
  if (res.status === 400 && serverNonce && jsonError(res.body) === USE_DPOP_NONCE) {
292
+ emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
212
293
  res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
213
294
  }
214
- return { response: tokenResponse(res), key: instanceKey };
295
+ return obtained(this.#tokenResponse(tokenEndpoint, res));
215
296
  }
216
- async #fetchChallenge(doc) {
217
- const endpoint = doc.challenge_endpoint;
297
+ async #fetchChallenge(endpoint) {
218
298
  if (!endpoint)
219
299
  return undefined;
220
300
  this.#checkSecure(endpoint, "challenge_endpoint");
@@ -222,13 +302,15 @@ export class ClientAttestation {
222
302
  // closed unless the caller opted out with requireChallenge: false.
223
303
  let body;
224
304
  try {
225
- body = await this.#getJson("POST", endpoint);
305
+ body = await this.#getJson("challenge", "POST", endpoint);
226
306
  }
227
307
  catch (err) {
228
308
  if (!(err instanceof ClientAttestationError))
229
309
  throw err;
230
- if (!this.#requireChallenge)
310
+ if (!this.#requireChallenge) {
311
+ emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
231
312
  return undefined;
313
+ }
232
314
  throw new ClientAttestationError(`could not fetch the attestation challenge from ${endpoint}: ${err.message}`, {
233
315
  status: err.status,
234
316
  body: err.body,
@@ -236,46 +318,29 @@ export class ClientAttestation {
236
318
  });
237
319
  }
238
320
  const challenge = body.attestation_challenge;
239
- if (typeof challenge === "string" && challenge)
321
+ if (typeof challenge === "string" && challenge) {
322
+ emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint) });
240
323
  return challenge;
241
- if (!this.#requireChallenge)
324
+ }
325
+ if (!this.#requireChallenge) {
326
+ emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
242
327
  return undefined;
328
+ }
243
329
  throw new ClientAttestationError(`challenge endpoint ${endpoint} returned no attestation_challenge`);
244
330
  }
245
- async #mint(doc, instanceKey, svid, challenge) {
246
- if (!doc.attestation_endpoint) {
247
- throw new ClientAttestationError("attester discovery document has no attestation_endpoint");
331
+ /** The parsed token response; a failure is logged as `http.failed` before it is thrown. */
332
+ #tokenResponse(endpoint, res) {
333
+ try {
334
+ return tokenResponse(res);
248
335
  }
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
- });
336
+ catch (err) {
337
+ const reason = err instanceof TransportError ? failureReason(err) : res.ok ? "malformed" : "status";
338
+ this.#failed("token", "POST", endpoint, reason, res.status);
339
+ throw err;
274
340
  }
275
- return response.attestation;
276
341
  }
277
- async #callTokenEndpoint(endpoint, authHeaders) {
278
- return tokenResponse(await this.#postToken(endpoint, authHeaders));
342
+ #failed(step, method, url, reason, status) {
343
+ emit(this.#logger, "warn", "http.failed", { step, method, ...urlParts(url), status, reason });
279
344
  }
280
345
  #postToken(endpoint, authHeaders) {
281
346
  const params = new URLSearchParams({ grant_type: "client_credentials" });
@@ -284,7 +349,7 @@ export class ClientAttestation {
284
349
  if (this.#operator)
285
350
  params.set("operator", canonicalJson(this.#operator));
286
351
  const form = params.toString();
287
- return this.#send(endpoint, {
352
+ return this.#send("token", endpoint, {
288
353
  method: "POST",
289
354
  headers: {
290
355
  "Content-Type": "application/x-www-form-urlencoded",
@@ -309,18 +374,35 @@ export class ClientAttestation {
309
374
  return;
310
375
  throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
311
376
  }
312
- async #getJson(method, url, jsonBody) {
377
+ async #getJson(step, method, url, jsonBody) {
313
378
  const headers = { Accept: "application/json" };
314
379
  let body;
315
380
  if (jsonBody !== undefined) {
316
381
  headers["Content-Type"] = "application/json";
317
382
  body = JSON.stringify(jsonBody);
318
383
  }
319
- return jsonOk(await this.#send(url, { method, headers, body }), { allowEmpty: true });
384
+ const res = await this.#send(step, url, { method, headers, body });
385
+ try {
386
+ return jsonOk(res, { allowEmpty: true });
387
+ }
388
+ catch (err) {
389
+ this.#failed(step, method, url, failureReason(err), res.status);
390
+ throw err;
391
+ }
320
392
  }
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 });
393
+ /**
394
+ * Every call the flow makes goes through the shared transport, with this client's timeout. A call that
395
+ * gets no usable response is logged as `http.failed`.
396
+ */
397
+ async #send(step, url, opts) {
398
+ try {
399
+ return await request(url, { ...opts, timeoutMs: this.#timeoutMs });
400
+ }
401
+ catch (err) {
402
+ const status = err instanceof ClientAttestationError ? err.status : undefined;
403
+ this.#failed(step, opts.method ?? "GET", url, failureReason(err), status);
404
+ throw err;
405
+ }
324
406
  }
325
407
  }
326
408
  const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
@@ -385,6 +467,8 @@ async function isNonceChallenge(res) {
385
467
  /**
386
468
  * Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
387
469
  * bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
470
+ *
471
+ * @internal
388
472
  */
389
473
  export function canonicalJson(value) {
390
474
  return JSON.stringify(sortKeys(value)).replace(/[\u0080-\uffff]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
@@ -398,21 +482,23 @@ function sortKeys(value) {
398
482
  }
399
483
  return value;
400
484
  }
401
- export function normalizeBaseUrl(pfHost) {
402
- let host = (pfHost ?? "").trim().replace(/\/+$/, "");
485
+ /** @internal */
486
+ export function normalizeBaseUrl(attesterUrl) {
487
+ let host = (attesterUrl ?? "").trim().replace(/\/+$/, "");
403
488
  if (!host) {
404
- throw new Error("pfHost is required");
489
+ throw new Error("attesterUrl is required");
405
490
  }
406
491
  if (!host.includes("://")) {
407
492
  host = "https://" + host;
408
493
  }
409
494
  return host;
410
495
  }
496
+ /** @internal */
411
497
  export function jwtSub(compactJwt) {
412
498
  const payload = compactJwt.split(".")[1] ?? "";
413
499
  let claims;
414
500
  try {
415
- claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
501
+ claims = JSON.parse(utf8Decode(base64url.decode(payload)));
416
502
  }
417
503
  catch {
418
504
  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");