@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/README.md +142 -22
- package/dist/attester.d.ts +23 -9
- package/dist/attester.js +40 -14
- package/dist/builders.d.ts +11 -3
- package/dist/builders.js +157 -16
- package/dist/credential.d.ts +2 -0
- package/dist/credential.js +2 -0
- package/dist/errors.d.ts +6 -2
- package/dist/errors.js +6 -2
- package/dist/evidence.d.ts +3 -1
- package/dist/evidence.js +46 -8
- package/dist/http.js +69 -14
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -4
- package/dist/issuance.d.ts +19 -5
- package/dist/issuance.js +38 -15
- package/dist/logging.js +41 -7
- package/dist/runtime.js +9 -0
- package/dist/signer.d.ts +8 -1
- package/dist/signer.js +32 -5
- package/dist/signing-key-pair.d.ts +6 -0
- package/dist/signing-key-pair.js +91 -5
- package/dist/svid-source.d.ts +14 -2
- package/dist/svid-source.js +200 -16
- package/dist/token-source.d.ts +28 -4
- package/dist/token-source.js +285 -63
- package/dist/validator/config.d.ts +39 -5
- package/dist/validator/config.js +89 -8
- package/dist/validator/index.d.ts +2 -2
- package/dist/validator/index.js +2 -2
- package/dist/validator/jwks.js +101 -22
- package/dist/validator/resource.d.ts +8 -0
- package/dist/validator/resource.js +50 -3
- package/dist/validator/strict.js +6 -1
- package/dist/validator/validator.js +50 -13
- package/dist/www-authenticate.d.ts +7 -0
- package/dist/www-authenticate.js +114 -0
- package/package.json +2 -1
package/dist/builders.js
CHANGED
|
@@ -35,9 +35,14 @@ export class ClientAttestationBuilder {
|
|
|
35
35
|
this.#clientId = clientId;
|
|
36
36
|
return this;
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
|
212
|
-
*
|
|
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
|
-
*
|
|
269
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
410
|
+
return path.replace(/[^\x00-\x7f]+/g, (run) => encodeURIComponent(run)); // throws on a lone surrogate
|
|
278
411
|
}
|
|
279
412
|
catch {
|
|
280
|
-
|
|
413
|
+
throw new Error(HTU_PATH);
|
|
281
414
|
}
|
|
282
|
-
|
|
283
|
-
|
|
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
|
}
|
package/dist/credential.d.ts
CHANGED
|
@@ -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.
|
package/dist/credential.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
23
|
+
* 512 characters of a non-2xx one.
|
|
20
24
|
*/
|
|
21
25
|
export class TransportError extends ClientAttestationError {
|
|
22
26
|
kind;
|
package/dist/evidence.d.ts
CHANGED
|
@@ -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 {
|
|
66
|
-
|
|
67
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
170
|
-
throw new TransportError(`${res.method} ${res.url} returned malformed 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
|
|
25
|
-
/**
|
|
26
|
-
|
|
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
|
|
19
|
-
/**
|
|
20
|
-
|
|
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";
|
package/dist/issuance.d.ts
CHANGED
|
@@ -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)
|
|
16
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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>;
|