@idpartners/client-attestation-sdk 1.0.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/builders.js CHANGED
@@ -35,9 +35,14 @@ export class ClientAttestationBuilder {
35
35
  this.#clientId = clientId;
36
36
  return this;
37
37
  }
38
- /** Binds the client instance key as `cnf.jwk`. Must be a public JWK. */
38
+ /**
39
+ * Binds the client instance key as `cnf.jwk`: an EC (`crv` P-256, P-384 or P-521, `x`, `y`) or RSA
40
+ * (`n`, `e`) public key. Throws for one carrying private or secret members (`d`, `p`, `q`, `dp`, `dq`,
41
+ * `qi`, `oth`, `k`) or another `kty`. Only those members and an optional `kid` are copied into the
42
+ * attestation.
43
+ */
39
44
  confirmationJwk(publicInstanceJwk) {
40
- this.#cnfJwk = publicInstanceJwk;
45
+ this.#cnfJwk = confirmationJwkMembers(publicInstanceJwk);
41
46
  return this;
42
47
  }
43
48
  /** Convenience: binds the public half of the given instance key as `cnf.jwk`. */
@@ -122,14 +127,61 @@ export class ClientAttestationBuilder {
122
127
  return signExternal(claims, this.#attesterKey, ATTESTATION_TYP);
123
128
  }
124
129
  #resolveExpiry(iat) {
130
+ let exp;
125
131
  if (this.#expiresAt !== null) {
126
- return this.#expiresAt;
132
+ exp = this.#expiresAt;
133
+ }
134
+ else if (this.#ttl !== null) {
135
+ exp = iat + this.#ttl;
136
+ }
137
+ else {
138
+ throw new Error("expiry is required: call expiresAt(...) or expiresIn(...)");
139
+ }
140
+ if (!(exp > iat)) {
141
+ throw new Error(`attestation exp (${exp}) must be after iat (${iat})`);
142
+ }
143
+ return exp;
144
+ }
145
+ }
146
+ /** JWK members that carry private or secret key material (RFC 7518 section 6). */
147
+ const PRIVATE_JWK_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
148
+ /** The public members a `cnf.jwk` of each key type must carry. */
149
+ const CONFIRMATION_JWK_MEMBERS = { EC: ["crv", "x", "y"], RSA: ["n", "e"] };
150
+ /**
151
+ * The public key to bind as `cnf.jwk`: `kty`, the key type's public members and an optional `kid`, in the
152
+ * order the input has them. Refuses private or secret members, another `kty` or curve, or a missing member,
153
+ * so a private key is never signed into an attestation.
154
+ */
155
+ function confirmationJwkMembers(jwk) {
156
+ if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) {
157
+ throw new Error("cnf.jwk must be a JWK object");
158
+ }
159
+ const members = jwk;
160
+ for (const member of PRIVATE_JWK_MEMBERS) {
161
+ if (member in members) {
162
+ throw new Error(`cnf.jwk must be a public key: it carries the private member '${member}'`);
127
163
  }
128
- if (this.#ttl !== null) {
129
- return iat + this.#ttl;
164
+ }
165
+ const kty = members.kty;
166
+ const required = typeof kty === "string" && Object.hasOwn(CONFIRMATION_JWK_MEMBERS, kty)
167
+ ? CONFIRMATION_JWK_MEMBERS[kty]
168
+ : undefined;
169
+ if (!required) {
170
+ throw new Error(`cnf.jwk must be an EC or RSA public key, not kty ${JSON.stringify(kty)}`);
171
+ }
172
+ for (const member of required) {
173
+ if (typeof members[member] !== "string" || members[member] === "") {
174
+ throw new Error(`cnf.jwk (kty ${kty}) is missing '${member}'`);
130
175
  }
131
- throw new Error("expiry is required: call expiresAt(...) or expiresIn(...)");
132
176
  }
177
+ if (kty === "EC" && !["P-256", "P-384", "P-521"].includes(members.crv)) {
178
+ throw new Error(`cnf.jwk curve must be P-256, P-384 or P-521, not ${JSON.stringify(members.crv)}`);
179
+ }
180
+ if ("kid" in members && (typeof members.kid !== "string" || members.kid === "")) {
181
+ throw new Error("cnf.jwk kid must be a non-empty string");
182
+ }
183
+ const keep = new Set(["kty", "kid", ...required]);
184
+ return Object.fromEntries(Object.entries(members).filter(([k]) => keep.has(k)));
133
185
  }
134
186
  /**
135
187
  * Builds a Client Attestation PoP JWT proving possession of the instance key. Client side of
@@ -208,8 +260,11 @@ export class DpopProofBuilder {
208
260
  return this;
209
261
  }
210
262
  /**
211
- * The target URL (`htu`) — required. Normalised when built: query and fragment are dropped, and
212
- * scheme and host lower-cased (RFC 9449 section 4.2).
263
+ * The target URL (`htu`) — required. Normalised when built (RFC 9449 section 4.2) by the rule in
264
+ * vectors/README.md: userinfo, query, fragment and a default port are dropped, and scheme and host
265
+ * lower-cased. `build` refuses a URL that is not an absolute http(s) URL with a valid ASCII host, port
266
+ * and path, or that has a backslash, ASCII whitespace or a control character before its query or
267
+ * fragment.
213
268
  */
214
269
  uri(htu) {
215
270
  this.#htu = htu;
@@ -265,20 +320,106 @@ export async function accessTokenHash(accessToken) {
265
320
  return base64url.encode(new Uint8Array(digest));
266
321
  }
267
322
  /**
268
- * The DPoP `htu` for a URL: scheme, host, port and path, with no query and no fragment. Scheme and host
269
- * are lower-cased and a default port dropped; a URL with no path keeps none (no slash is invented).
323
+ * Throws unless `url` is free of backslashes, ASCII whitespace and control characters before its query or
324
+ * fragment (the part that becomes `htu`). URL parsers disagree on where such a URL goes - WHATWG reads
325
+ * `https://evil.example\@as.example/t` as a request to evil.example, `urllib.parse` as one to
326
+ * as.example - so a proof's `htu` could name a different server from the one the request reaches.
270
327
  *
271
328
  * @internal
272
329
  */
273
- export function normalizeHtu(url) {
330
+ export function checkHtu(url) {
331
+ const head = url.split(/[?#]/, 1)[0];
332
+ if (/[\x00-\x20\x7f\\]/.test(head)) {
333
+ throw new Error("uri (htu) must not contain a backslash, whitespace or control character");
334
+ }
335
+ }
336
+ /** The schemes an `htu` may have, with their default ports. */
337
+ const HTU_DEFAULT_PORTS = { http: "80", https: "443" };
338
+ const HTU_NOT_ABSOLUTE = "uri (htu) must be an absolute http or https URL with a host";
339
+ const HTU_HOST = "uri (htu) has an invalid host";
340
+ const HTU_PORT = "uri (htu) has an invalid port";
341
+ const HTU_PATH = "uri (htu) has an invalid path";
342
+ /**
343
+ * `[origin, path]` of a request URL's `htu`, by the rule in vectors/README.md that every port applies
344
+ * alike: an absolute http(s) URL with a host; scheme and host lower-cased (ASCII only, an IPv6 literal as
345
+ * written); no userinfo; the port as a plain number and dropped when it is the scheme's default; the path
346
+ * as written with non-ASCII characters percent-encoded (UTF-8); no query or fragment. It
347
+ * is read from the string itself, not through WHATWG `URL`, which would also resolve dot-segments,
348
+ * compress IPv6 and convert IDN hosts. Anything else throws.
349
+ *
350
+ * @internal
351
+ */
352
+ export function htuParts(url) {
353
+ checkHtu(url);
274
354
  const bare = url.split(/[?#]/, 1)[0];
275
- let parsed;
355
+ const sep = bare.indexOf("://");
356
+ const scheme = sep < 0 ? "" : bare.slice(0, sep).toLowerCase();
357
+ if (!Object.hasOwn(HTU_DEFAULT_PORTS, scheme)) {
358
+ throw new Error(HTU_NOT_ABSOLUTE);
359
+ }
360
+ const rest = bare.slice(sep + 3);
361
+ const slash = rest.indexOf("/");
362
+ const authority = slash < 0 ? rest : rest.slice(0, slash);
363
+ const path = slash < 0 ? "" : rest.slice(slash);
364
+ const hostport = authority.slice(authority.lastIndexOf("@") + 1);
365
+ let host;
366
+ let port = "";
367
+ if (hostport.startsWith("[")) {
368
+ const close = hostport.indexOf("]");
369
+ const literal = close < 0 ? "" : hostport.slice(1, close);
370
+ const after = close < 0 ? "" : hostport.slice(close + 1);
371
+ if (!literal.includes(":") || !/^[0-9A-Fa-f:.]*$/.test(literal) || (after !== "" && after[0] !== ":")) {
372
+ throw new Error(HTU_HOST);
373
+ }
374
+ host = `[${literal.toLowerCase()}]`;
375
+ port = after.slice(1);
376
+ }
377
+ else {
378
+ const colon = hostport.indexOf(":");
379
+ host = colon < 0 ? hostport : hostport.slice(0, colon);
380
+ port = colon < 0 ? "" : hostport.slice(colon + 1);
381
+ if (host === "") {
382
+ throw new Error(HTU_NOT_ABSOLUTE);
383
+ }
384
+ if (!/^[A-Za-z0-9\-._~]+$/.test(host)) {
385
+ throw new Error(HTU_HOST);
386
+ }
387
+ host = host.toLowerCase();
388
+ }
389
+ if (port !== "") {
390
+ const digits = port.replace(/^0+/, "");
391
+ if (!/^[0-9]+$/.test(port) || digits.length > 5 || Number(digits) > 65535) {
392
+ throw new Error(HTU_PORT);
393
+ }
394
+ const n = String(Number(digits));
395
+ if (n !== HTU_DEFAULT_PORTS[scheme]) {
396
+ host += `:${n}`;
397
+ }
398
+ }
399
+ return [`${scheme}://${host}`, encodeHtuPath(path)];
400
+ }
401
+ /**
402
+ * `path` with every non-ASCII character percent-encoded as UTF-8 (upper-case hex) and every ASCII
403
+ * character kept as written, `%XX` escapes, a lone `%` and characters outside RFC 3986 `pchar` included:
404
+ * those are what the HTTP client sends, and a server compares `htu` after RFC 3986 section 6.2.2
405
+ * normalisation, which decodes only unreserved characters. (`checkHtu` has already refused controls,
406
+ * whitespace and backslash.) A lone surrogate is refused.
407
+ */
408
+ function encodeHtuPath(path) {
276
409
  try {
277
- parsed = new URL(bare);
410
+ return path.replace(/[^\x00-\x7f]+/g, (run) => encodeURIComponent(run)); // throws on a lone surrogate
278
411
  }
279
412
  catch {
280
- return bare;
413
+ throw new Error(HTU_PATH);
281
414
  }
282
- const hasPath = /^[a-z][a-z0-9+.-]*:\/\/[^/]*\//i.test(bare);
283
- return `${parsed.protocol}//${parsed.host}${hasPath ? parsed.pathname : ""}`;
415
+ }
416
+ /**
417
+ * The DPoP `htu` for a URL (RFC 9449 section 4.2): the origin and path {@link htuParts} gives. A URL
418
+ * with no path keeps none (no slash is invented).
419
+ *
420
+ * @internal
421
+ */
422
+ export function normalizeHtu(url) {
423
+ const [origin, path] = htuParts(url);
424
+ return origin + path;
284
425
  }
@@ -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/errors.d.ts CHANGED
@@ -2,7 +2,11 @@
2
2
  * The SDK's error types. {@link ClientAttestationError} is the base for every failure of the
3
3
  * one-call flow; {@link TransportError} is the subclass for anything that went wrong on the wire.
4
4
  */
5
- /** A step in the flow failed. `status` and `body` are set for HTTP failures. */
5
+ /**
6
+ * A step in the flow failed. `status` and `body` are set for HTTP failures: `body` is at most the first 512
7
+ * characters of a non-2xx response, and unset for a 2xx response that could not be used, since that body
8
+ * may hold a token, attestation or SVID.
9
+ */
6
10
  export declare class ClientAttestationError extends Error {
7
11
  readonly status?: number;
8
12
  readonly body?: string;
@@ -17,7 +21,7 @@ export type TransportFailure = "network" | "timeout" | "redirect" | "too_large"
17
21
  /**
18
22
  * An outbound HTTP call failed: unreachable, timed out, redirected, oversized, non-2xx, or malformed
19
23
  * JSON (see {@link kind}). `status` is set when a response was received; `body` holds at most the first
20
- * 512 characters of it.
24
+ * 512 characters of a non-2xx one.
21
25
  */
22
26
  export declare class TransportError extends ClientAttestationError {
23
27
  readonly kind: TransportFailure;
package/dist/errors.js CHANGED
@@ -2,7 +2,11 @@
2
2
  * The SDK's error types. {@link ClientAttestationError} is the base for every failure of the
3
3
  * one-call flow; {@link TransportError} is the subclass for anything that went wrong on the wire.
4
4
  */
5
- /** A step in the flow failed. `status` and `body` are set for HTTP failures. */
5
+ /**
6
+ * A step in the flow failed. `status` and `body` are set for HTTP failures: `body` is at most the first 512
7
+ * characters of a non-2xx response, and unset for a 2xx response that could not be used, since that body
8
+ * may hold a token, attestation or SVID.
9
+ */
6
10
  export class ClientAttestationError extends Error {
7
11
  status;
8
12
  body;
@@ -16,7 +20,7 @@ export class ClientAttestationError extends Error {
16
20
  /**
17
21
  * An outbound HTTP call failed: unreachable, timed out, redirected, oversized, non-2xx, or malformed
18
22
  * JSON (see {@link kind}). `status` is set when a response was received; `body` holds at most the first
19
- * 512 characters of it.
23
+ * 512 characters of a non-2xx one.
20
24
  */
21
25
  export class TransportError extends ClientAttestationError {
22
26
  kind;
@@ -19,6 +19,7 @@ export declare class EvidenceNotFound extends Error {
19
19
  * Kubernetes and GKE mount a short-lived, auto-rotated service-account token into the pod as a file
20
20
  * (a projected `serviceAccountToken` volume). Reading it fresh each time picks up the rotation. The
21
21
  * file's audience must already be the attester's evidence audience — set it when you declare the volume.
22
+ * The path must be a regular file of at most 64 KiB; anything else is an {@link EvidenceNotFound}.
22
23
  *
23
24
  * Needs a filesystem: `node:fs/promises` is loaded on the first `fetch`, which throws a
24
25
  * `ClientAttestationError` on a runtime without it.
@@ -33,7 +34,8 @@ export declare class FileTokenSvidSource implements SvidSource {
33
34
  *
34
35
  * Works on any Google runtime that exposes the metadata server (GCE, GKE, Cloud Run, Vertex AI Agent
35
36
  * Engine). The token's `aud` is the attester's evidence audience; `sub` is the service account. No
36
- * google-auth dependency — a plain metadata call.
37
+ * google-auth dependency — a plain metadata call. The answer must be HTTP 200 carrying the
38
+ * `Metadata-Flavor: Google` response header, as the real metadata server sends.
37
39
  */
38
40
  export declare class GoogleMetadataSvidSource implements SvidSource {
39
41
  private readonly timeoutMs;
package/dist/evidence.js CHANGED
@@ -10,12 +10,12 @@
10
10
  * - the GCP metadata server (GCE, GKE, Cloud Run, Vertex AI Agent Engine).
11
11
  *
12
12
  * {@link autoDetectEvidence} tries these in order and returns the first that applies, so a developer
13
- * can write `new ClientAttestation(attesterUrl)` and have it work without naming the source. Override by
13
+ * can write `new ClientAttestation(attesterUrl, { evidenceAudience })` and have it work without naming the source. Override by
14
14
  * passing an explicit {@link SvidSource} to {@link ClientAttestation}.
15
15
  */
16
16
  import { TransportError } from "./errors.js";
17
17
  import { request, snippet } from "./http.js";
18
- import { envVar, importNodeBuiltin } from "./runtime.js";
18
+ import { envVar, importNodeBuiltin, utf8Decode } from "./runtime.js";
19
19
  import { SpireWorkloadApiSvidSource, StaticSvidSource } from "./svid-source.js";
20
20
  /** Environment variable holding the evidence token itself (static). */
21
21
  export const ENV_SVID = "CLIENT_ATTESTATION_SVID";
@@ -39,6 +39,8 @@ export const GCP_METADATA_BASE = "http://metadata.google.internal";
39
39
  * @internal
40
40
  */
41
41
  export const GCP_METADATA_IDENTITY = GCP_METADATA_BASE + "/computeMetadata/v1/instance/service-accounts/default/identity";
42
+ /** A token file larger than this (64 KiB) is refused: a projected token is a few KiB. */
43
+ const MAX_TOKEN_FILE_BYTES = 64 * 1024;
42
44
  /** No platform identity could be detected and none was supplied. */
43
45
  export class EvidenceNotFound extends Error {
44
46
  constructor(message) {
@@ -52,6 +54,7 @@ export class EvidenceNotFound extends Error {
52
54
  * Kubernetes and GKE mount a short-lived, auto-rotated service-account token into the pod as a file
53
55
  * (a projected `serviceAccountToken` volume). Reading it fresh each time picks up the rotation. The
54
56
  * file's audience must already be the attester's evidence audience — set it when you declare the volume.
57
+ * The path must be a regular file of at most 64 KiB; anything else is an {@link EvidenceNotFound}.
55
58
  *
56
59
  * Needs a filesystem: `node:fs/promises` is loaded on the first `fetch`, which throws a
57
60
  * `ClientAttestationError` on a runtime without it.
@@ -62,9 +65,29 @@ export class FileTokenSvidSource {
62
65
  this.path = path;
63
66
  }
64
67
  async fetch(_audience) {
65
- const { readFile } = await importNodeBuiltin("fs/promises", "FileTokenSvidSource");
66
- const contents = await readFile(this.path, "utf8");
67
- return contents.trim();
68
+ const { open, stat } = await importNodeBuiltin("fs/promises", "FileTokenSvidSource");
69
+ // Checked before opening: opening a FIFO blocks, and a device such as /dev/zero never ends.
70
+ if (!(await stat(this.path)).isFile()) {
71
+ throw new EvidenceNotFound(`token file ${this.path} is not a regular file`);
72
+ }
73
+ const handle = await open(this.path, "r");
74
+ try {
75
+ const buf = new Uint8Array(MAX_TOKEN_FILE_BYTES + 1);
76
+ let total = 0;
77
+ for (;;) {
78
+ const { bytesRead } = await handle.read(buf, total, buf.length - total, null);
79
+ if (bytesRead === 0)
80
+ break;
81
+ total += bytesRead;
82
+ if (total > MAX_TOKEN_FILE_BYTES) {
83
+ throw new EvidenceNotFound(`token file ${this.path} is larger than ${MAX_TOKEN_FILE_BYTES} bytes`);
84
+ }
85
+ }
86
+ return utf8Decode(buf.subarray(0, total)).trim();
87
+ }
88
+ finally {
89
+ await handle.close();
90
+ }
68
91
  }
69
92
  }
70
93
  /**
@@ -72,7 +95,8 @@ export class FileTokenSvidSource {
72
95
  *
73
96
  * Works on any Google runtime that exposes the metadata server (GCE, GKE, Cloud Run, Vertex AI Agent
74
97
  * Engine). The token's `aud` is the attester's evidence audience; `sub` is the service account. No
75
- * google-auth dependency — a plain metadata call.
98
+ * google-auth dependency — a plain metadata call. The answer must be HTTP 200 carrying the
99
+ * `Metadata-Flavor: Google` response header, as the real metadata server sends.
76
100
  */
77
101
  export class GoogleMetadataSvidSource {
78
102
  timeoutMs;
@@ -92,12 +116,17 @@ export class GoogleMetadataSvidSource {
92
116
  headers: { "Metadata-Flavor": "Google" },
93
117
  timeoutMs: this.timeoutMs,
94
118
  });
95
- if (!res.ok) {
119
+ if (res.status !== 200) {
96
120
  throw new TransportError(`GCP metadata identity endpoint -> ${res.status}`, "status", {
97
121
  status: res.status,
98
122
  body: snippet(res.body),
99
123
  });
100
124
  }
125
+ if (!fromGoogleMetadata(res.headers)) {
126
+ throw new TransportError("GCP metadata identity endpoint answered without Metadata-Flavor: Google", "status", {
127
+ status: res.status,
128
+ });
129
+ }
101
130
  return res.body.trim();
102
131
  }
103
132
  }
@@ -114,6 +143,15 @@ async function pathProbe() {
114
143
  return () => false;
115
144
  }
116
145
  }
146
+ /** Whether a response carries the header the GCP metadata server always sets. */
147
+ function fromGoogleMetadata(headers) {
148
+ return headers.get("Metadata-Flavor") === "Google";
149
+ }
150
+ /**
151
+ * Whether the GCP metadata server answers: HTTP 200 with `Metadata-Flavor: Google`.
152
+ * `metadata.google.internal` is a DNS name, so whatever a search domain, LLMNR or a hostile resolver
153
+ * points it at could answer; the header is what google-auth checks too.
154
+ */
117
155
  async function gcpMetadataAvailable(base, timeoutMs = 300) {
118
156
  try {
119
157
  const res = await request(base + "/computeMetadata/v1/", {
@@ -121,7 +159,7 @@ async function gcpMetadataAvailable(base, timeoutMs = 300) {
121
159
  headers: { "Metadata-Flavor": "Google" },
122
160
  timeoutMs,
123
161
  });
124
- return res.ok;
162
+ return res.status === 200 && fromGoogleMetadata(res.headers);
125
163
  }
126
164
  catch {
127
165
  return false;
package/dist/http.js CHANGED
@@ -9,20 +9,50 @@
9
9
  * - redirects are never followed — a 3xx is a {@link TransportError}, and the target is never contacted;
10
10
  * - the response body is capped (default 1 MiB), by `content-length` and again while streaming;
11
11
  * - {@link request} returns non-2xx responses as data; {@link jsonOk} turns them, and malformed JSON,
12
- * into a {@link TransportError}.
12
+ * into a {@link TransportError};
13
+ * - a URL with userinfo (`user:password@`) is refused, and errors name a URL without its userinfo, query or
14
+ * fragment.
15
+ *
16
+ * Proxies: `fetch` uses no proxy from the environment by default (Node, Deno, Bun without configuration,
17
+ * edge runtimes). Node started with `NODE_USE_ENV_PROXY=1` or `--use-env-proxy` sends every request,
18
+ * plain http included, to `HTTP_PROXY` / `HTTPS_PROXY`; the SDK can't pick a dispatcher per request
19
+ * without depending on undici, so the other ports' rule (a proxy only for https to a remote host) is
20
+ * then the operator's to keep, with `NO_PROXY`.
13
21
  */
14
- import { TransportError } from "./errors.js";
22
+ import { ClientAttestationError, TransportError } from "./errors.js";
15
23
  import { utf8Decode, utf8Encode } from "./runtime.js";
16
24
  /** @internal */
17
25
  export const DEFAULT_MAX_BYTES = 1024 * 1024;
18
26
  /** @internal */
19
27
  export const DEFAULT_TIMEOUT_MS = 20_000;
20
28
  /**
21
- * At most this many characters of a response body are carried in an error.
29
+ * At most this many characters of a non-2xx response body are carried in an error. A 2xx body never is:
30
+ * that is where access tokens, attestations and SVIDs travel.
22
31
  *
23
32
  * @internal
24
33
  */
25
34
  export const ERROR_BODY_CHARS = 512;
35
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
36
+ /**
37
+ * Refuse a non-https URL unless it is loopback or the caller passed `allowInsecure`. Shared by every SDK
38
+ * client that sends credentials.
39
+ *
40
+ * @internal
41
+ */
42
+ export function checkSecure(url, what, allowInsecure) {
43
+ if (allowInsecure)
44
+ return;
45
+ let parsed;
46
+ try {
47
+ parsed = new URL(url);
48
+ }
49
+ catch {
50
+ throw new ClientAttestationError(`${what} is not a valid URL: ${url}`);
51
+ }
52
+ if (parsed.protocol === "https:" || LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
53
+ return;
54
+ throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
55
+ }
26
56
  /**
27
57
  * The first {@link ERROR_BODY_CHARS} characters of a body, for an error message.
28
58
  *
@@ -31,6 +61,30 @@ export const ERROR_BODY_CHARS = 512;
31
61
  export function snippet(body) {
32
62
  return body.length > ERROR_BODY_CHARS ? body.slice(0, ERROR_BODY_CHARS) : body;
33
63
  }
64
+ /**
65
+ * `url` without its userinfo, query or fragment, for error messages: a query can carry a secret, and
66
+ * userinfo is one.
67
+ *
68
+ * @internal
69
+ */
70
+ export function displayUrl(url) {
71
+ const beforeQuery = url.split(/[?#]/, 1)[0] ?? "";
72
+ return beforeQuery.replace(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/\\]*@/, "$1");
73
+ }
74
+ /**
75
+ * Whether `url` carries userinfo, read as `fetch` would read it; an unparseable URL is checked by hand.
76
+ *
77
+ * @internal
78
+ */
79
+ export function hasUserinfo(url) {
80
+ try {
81
+ const parsed = new URL(url);
82
+ return parsed.username !== "" || parsed.password !== "";
83
+ }
84
+ catch {
85
+ return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^/?#\\]*@/.test(url);
86
+ }
87
+ }
34
88
  function describe(err) {
35
89
  return err instanceof Error ? err.message : String(err);
36
90
  }
@@ -49,7 +103,11 @@ export async function request(url, opts = {}) {
49
103
  const method = opts.method ?? "GET";
50
104
  const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
51
105
  const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
52
- const where = `${method} ${url}`;
106
+ const where = `${method} ${displayUrl(url)}`;
107
+ if (hasUserinfo(url)) {
108
+ // fetch refuses these too, but its error would come after `where` held the userinfo
109
+ throw new TransportError(`${where}: URLs with userinfo (user:password@) are not supported`, "network");
110
+ }
53
111
  const timedOut = (cause) => new TransportError(`${where} timed out after ${timeoutMs} ms`, "timeout", { cause });
54
112
  let res;
55
113
  try {
@@ -147,7 +205,7 @@ async function readCapped(res, maxBytes, tooLarge) {
147
205
  export function jsonOk(res, opts = {}) {
148
206
  if (!res.ok) {
149
207
  const body = snippet(res.body);
150
- throw new TransportError(`${res.method} ${res.url} returned HTTP ${res.status}: ${body}`, "status", {
208
+ throw new TransportError(`${res.method} ${displayUrl(res.url)} returned HTTP ${res.status}: ${body}`, "status", {
151
209
  status: res.status,
152
210
  body,
153
211
  });
@@ -155,7 +213,9 @@ export function jsonOk(res, opts = {}) {
155
213
  return parseJsonObject(res, opts);
156
214
  }
157
215
  /**
158
- * Parse `res.body` as a JSON object regardless of status; a {@link TransportError} if it is not one.
216
+ * Parse `res.body` as a JSON object regardless of status; a {@link TransportError} if it is not one. The
217
+ * error carries no body, no cause and not the parser's message, which quotes part of the text: the body
218
+ * parsed here is a 2xx one, which carries tokens, attestations and SVIDs.
159
219
  *
160
220
  * @internal
161
221
  */
@@ -166,17 +226,12 @@ export function parseJsonObject(res, opts = {}) {
166
226
  try {
167
227
  parsed = JSON.parse(res.body);
168
228
  }
169
- catch (err) {
170
- throw new TransportError(`${res.method} ${res.url} returned malformed JSON: ${describe(err)}`, "json", {
171
- status: res.status,
172
- body: snippet(res.body),
173
- cause: err,
174
- });
229
+ catch {
230
+ throw new TransportError(`${res.method} ${displayUrl(res.url)} returned malformed JSON`, "json", { status: res.status });
175
231
  }
176
232
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
177
- throw new TransportError(`${res.method} ${res.url} did not return a JSON object`, "json", {
233
+ throw new TransportError(`${res.method} ${displayUrl(res.url)} did not return a JSON object`, "json", {
178
234
  status: res.status,
179
- body: snippet(res.body),
180
235
  });
181
236
  }
182
237
  return parsed;
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.0.1";
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.0.1";
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";
@@ -12,20 +12,26 @@ import type { SvidSource } from "./svid-source.js";
12
12
  /** The instance-key proof typ the PingFederate profile presents (server InstanceKeyProofValidator.TYP). */
13
13
  export { INSTANCE_PROOF_TYP } from "./attester.js";
14
14
  /**
15
- * Thrown when the issuance endpoint rejects the request (non-2xx). Transport failures — unreachable,
16
- * timeout, redirect, oversized or malformed response — are a {@link TransportError} instead.
15
+ * Thrown when the issuance endpoint rejects the request (non-2xx); `body` is at most the first 512
16
+ * characters of the response. Transport failures — unreachable, timeout, redirect, oversized or malformed
17
+ * response — are a {@link TransportError} instead.
17
18
  */
18
19
  export declare class IssuanceError extends Error {
19
20
  readonly status: number;
20
21
  readonly body: string;
21
22
  constructor(status: number, body: string);
22
23
  }
23
- /** A minted Client Attestation plus the instance key it is bound to. */
24
+ /**
25
+ * A minted Client Attestation plus the instance key it is bound to. The attestation is a getter over a
26
+ * private field, so `JSON.stringify` and `console.log` of this object don't show it.
27
+ */
24
28
  export declare class IssuedAttestation {
25
- readonly attestation: string;
29
+ #private;
26
30
  readonly expiresIn: number | undefined;
27
31
  readonly instanceKey: SigningKeyPair;
28
32
  constructor(attestation: string, expiresIn: number | undefined, instanceKey: SigningKeyPair);
33
+ /** The Client Attestation (a compact JWT). */
34
+ get attestation(): string;
29
35
  /** A fresh Client Attestation PoP for the token endpoint, signed by the bound instance key. */
30
36
  pop(audience: string, clientId?: string, challenge?: string): Promise<string>;
31
37
  /**
@@ -39,6 +45,9 @@ export declare class IssuedAttestation {
39
45
  *
40
46
  * `opts.profile` sets the mint protocol (default {@link PingFederateProfile}; see
41
47
  * docs/attester-profiles.md). The endpoint is given directly, so the profile's discovery is not used.
48
+ *
49
+ * The SVID is a bearer credential to the attester, so the endpoint, and every URL the profile calls, must be
50
+ * https unless its host is loopback (`localhost`, `127.0.0.1`, `::1`) or `opts.allowInsecure` is set.
42
51
  *
43
52
  * @experimental See docs/api.md: it calls a PingFederate demo issuance servlet (/federation/attestation)
44
53
  * whose protocol is not standardised.
@@ -50,11 +59,16 @@ export declare class AttestationIssuanceClient {
50
59
  private readonly source;
51
60
  readonly instanceKey: SigningKeyPair;
52
61
  readonly profile: AttesterProfile;
62
+ private readonly transport;
53
63
  private constructor();
54
- /** Generate a fresh ES256 instance key and return a ready client. */
64
+ /**
65
+ * Generate a fresh ES256 instance key and return a ready client. `opts.allowInsecure` permits plain-http
66
+ * URLs on hosts that are not loopback (default `false`).
67
+ */
55
68
  static create(endpoint: string, clientId: string, attesterIssuer: string, source: SvidSource, opts?: {
56
69
  instanceKey?: SigningKeyPair;
57
70
  profile?: AttesterProfile;
71
+ allowInsecure?: boolean;
58
72
  }): Promise<AttestationIssuanceClient>;
59
73
  /** Fetch the SVID, prove possession of the instance key, and exchange them for a minted attestation. */
60
74
  bootstrap(authorizationDetails?: Array<Record<string, unknown>>, challenge?: string): Promise<IssuedAttestation>;