@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.
package/README.md CHANGED
@@ -17,10 +17,32 @@ ports and is judged by a shared Java verifier. Built on [panva `jose`](https://g
17
17
 
18
18
  ## Install / build
19
19
 
20
- Requires **Node 20+**. The package uses Node built-ins (`node:crypto`, `node:fs`, and
21
- `node:child_process` for the SPIRE evidence source), so it does not run on edge or browser runtimes.
20
+ Requires **Node 20+**, or an edge runtime with WebCrypto and `fetch` (Cloudflare Workers, Vercel Edge,
21
+ Deno Deploy) with no Node compatibility layer needed. Crypto, encoding and HTTP use web-standard APIs, and
22
+ nothing imports a Node built-in at load time, so the builders, the one-call `ClientAttestation` flow, the
23
+ issuance client and the token validator all work on the edge.
24
+
25
+ CI tests this in a real Workers runtime: `npm run test:edge` bundles the package as an edge bundler would
26
+ and runs it in [`workerd`](https://github.com/cloudflare/workerd), Cloudflare's open-source Workers runtime
27
+ (1.20261007.1, compatibility date 2026-10-01, Node compatibility switched off). It generates keys, builds an
28
+ attestation, a PoP and a DPoP proof, and runs the token validator against the shared validation vectors.
29
+ Vercel Edge and Deno Deploy are not tested directly.
30
+
31
+ Evidence gathering is the part that needs Node. Each of these loads the built-in it needs on first use; the
32
+ two sources throw a `ClientAttestationError` naming themselves from `fetch` on a runtime without it:
33
+
34
+ - `SpireWorkloadApiSvidSource` runs the `spire-agent` CLI (`node:child_process`);
35
+ - `FileTokenSvidSource` reads a projected token file (`node:fs`);
36
+ - `autoDetectEvidence` reads environment variables and probes for token files and the SPIRE socket. On
37
+ the edge those all read as absent, so it falls through to the GCP metadata server and then throws
38
+ `EvidenceNotFound`. Pass an explicit `SvidSource` to `ClientAttestation` there: `StaticSvidSource`,
39
+ your own, or `HttpSvidSource` in development.
22
40
 
23
- Not on npm yet (it will publish as `@idpartners/client-attestation-sdk`). Build from a clone:
41
+ ```bash
42
+ npm install @idpartners/client-attestation-sdk
43
+ ```
44
+
45
+ To build from a clone:
24
46
 
25
47
  ```bash
26
48
  npm install
@@ -97,9 +119,17 @@ and the challenge response is returned instead. The token request itself retries
97
119
  - `challengeAtToken: false` keeps the attester challenge in the mint's instance proof but leaves it out
98
120
  of the token-endpoint PoP `challenge` claim / DPoP `nonce` - for attesters that consume a challenge at
99
121
  mint and reject it when the token request presents it again.
100
- - The PingFederate host and every endpoint the attester advertises must be `https`, except loopback
122
+ - The attester URL and every endpoint the attester advertises must be `https`, except loopback
101
123
  (`localhost`, `127.0.0.1`, `::1`). Pass `allowInsecure: true` for, say, an in-cluster plain-http PF.
102
124
 
125
+ ### Other attesters
126
+
127
+ `ClientAttestation` speaks PingFederate's discovery and mint protocol by default. For another attester,
128
+ pass the `profile` option (your own `AttesterProfile`), or the `metadata` option to give its endpoints
129
+ directly and skip discovery: `new ClientAttestation({ metadata: { evidenceAudience, attestationEndpoint,
130
+ tokenEndpoint } })`. `AttestationIssuanceClient.create` takes `{ profile }` too. See
131
+ [`docs/attester-profiles.md`](../docs/attester-profiles.md).
132
+
103
133
  The validator's `ProtectedResource` strips control characters from every quoted `WWW-Authenticate` value,
104
134
  and validation failures never echo the token's own `alg`, `kid`, `iss` or `aud` back in
105
135
  `error_description`.
@@ -108,5 +138,6 @@ and validation failures never echo the token's own `alg`, `kid`, `iss` or `aud`
108
138
 
109
139
  ```bash
110
140
  npm test # vitest — self-verifies each artifact with jose
141
+ npm run test:edge # the same SDK in workerd, Cloudflare's Workers runtime, with no Node compatibility
111
142
  npm run emit # writes ../vectors/out/typescript.json for the cross-language Java verifier
112
143
  ```
@@ -0,0 +1,93 @@
1
+ import { type SigningKeyPair } from "./signing-key-pair.js";
2
+ /** Where the attester lives and what it expects. */
3
+ export interface AttesterMetadata {
4
+ /** The audience the workload's platform identity is requested for, and the instance proof's `aud`. */
5
+ evidenceAudience?: string;
6
+ /** Where the mint request goes. */
7
+ attestationEndpoint?: string;
8
+ /** The authorization server's token endpoint. A profile fills in its own fallback. */
9
+ tokenEndpoint?: string;
10
+ /** The draft's challenge endpoint, if the attester issues challenges. */
11
+ challengeEndpoint?: string;
12
+ /** The Client Attestation PoP `aud` (the AS issuer); falls back to the token endpoint URL. */
13
+ popAudience?: string;
14
+ /** The raw discovery document, when there was one; returned by `ClientAttestation.discovery()`. */
15
+ document?: Record<string, unknown>;
16
+ }
17
+ /** What the attester returned: the attestation, and its lifetime in seconds if it said. */
18
+ export interface MintResult {
19
+ attestation: string;
20
+ expiresIn?: number;
21
+ }
22
+ /**
23
+ * How a profile talks to the attester. The client supplies it, so every call shares the client's timeout,
24
+ * redirect and size limits, https rule and logging (`http.failed`).
25
+ */
26
+ export interface AttesterTransport {
27
+ /**
28
+ * Send `body` (as JSON, if given) and resolve to the JSON object response. `step` names the call in
29
+ * logs (`discover`, `mint`). Rejects on a non-2xx status or a body that is not a JSON object.
30
+ */
31
+ requestJson(step: string, method: string, url: string, body?: Record<string, unknown>): Promise<Record<string, unknown>>;
32
+ }
33
+ /**
34
+ * The attester-specific part of the flow. Implement it and pass it as the `profile` option.
35
+ *
36
+ * @experimental See docs/api.md: new in 0.4.0 and fitted to one real attester so far; a method added to it would break custom profiles.
37
+ */
38
+ export interface AttesterProfile {
39
+ /**
40
+ * Find the attester's endpoints. Called once per client and cached; not called when the caller passes
41
+ * static `metadata`.
42
+ */
43
+ discover(transport: AttesterTransport, attesterUrl: string | undefined): Promise<AttesterMetadata>;
44
+ /** Exchange the evidence and a proof of the instance key for an attestation. */
45
+ mint(transport: AttesterTransport, metadata: AttesterMetadata, request: MintRequest): Promise<MintResult>;
46
+ }
47
+ /** Everything a profile needs to mint one attestation. */
48
+ export declare class MintRequest {
49
+ /** The fresh key the attestation will be bound to (its `cnf`). */
50
+ readonly instanceKey: SigningKeyPair;
51
+ /** The workload's platform identity token (a JWT-SVID or equivalent). */
52
+ readonly evidence: string;
53
+ /** The attester-issued challenge, if any. */
54
+ readonly challenge?: string;
55
+ /** Set by {@link AttestationIssuanceClient}; unset in the evidence-first one-call flow. */
56
+ readonly clientId?: string;
57
+ /** Optional RFC 9396 access request. */
58
+ readonly authorizationDetails?: Array<Record<string, unknown>>;
59
+ /** Optional operator object (who answers for this workload). */
60
+ readonly operator?: Record<string, unknown>;
61
+ constructor(init: {
62
+ instanceKey: SigningKeyPair;
63
+ evidence: string;
64
+ challenge?: string;
65
+ clientId?: string;
66
+ authorizationDetails?: Array<Record<string, unknown>>;
67
+ operator?: Record<string, unknown>;
68
+ });
69
+ /** Sign `claims` as a compact JWS with the instance key, under header `typ` (no `jwk`). */
70
+ sign(claims: Record<string, unknown>, typ: string): Promise<string>;
71
+ /** A proof of possession of the instance key: `{aud, jti, iat[, challenge]}` signed with it. */
72
+ instanceProof(typ: string, audience: string | undefined): Promise<string>;
73
+ }
74
+ /** The instance-key proof typ of the PingFederate profile (server InstanceKeyProofValidator.TYP). */
75
+ export declare const INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt";
76
+ /**
77
+ * The attester this SDK was built against (PingFederate), and the default.
78
+ *
79
+ * - Discovery: `GET {attesterUrl}/.well-known/client-attester`, a JSON object with `evidence_audience`,
80
+ * `attestation_endpoint`, `challenge_endpoint`, `token_endpoint` and `pop_audience`. With no
81
+ * `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
82
+ * - Mint: `POST attestation_endpoint` with JSON `{[client_id,] instance_key, svid, proof
83
+ * [, authorization_details][, operator]}`, where `proof` is `{aud: evidence_audience, jti, iat
84
+ * [, challenge]}` signed by the instance key with `typ` `oauth-attestation-instance-proof+jwt`. The
85
+ * response is `{"attestation": ..., "expires_in": ...}`.
86
+ */
87
+ export declare class PingFederateProfile implements AttesterProfile {
88
+ static readonly WELL_KNOWN_PATH = "/.well-known/client-attester";
89
+ static readonly INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt";
90
+ static readonly DEFAULT_TOKEN_PATH = "/as/token.oauth2";
91
+ discover(transport: AttesterTransport, attesterUrl: string | undefined): Promise<AttesterMetadata>;
92
+ mint(transport: AttesterTransport, metadata: AttesterMetadata, request: MintRequest): Promise<MintResult>;
93
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Attester profiles: the part of the flow that differs from one attester to the next.
3
+ *
4
+ * The draft says what a Client Attestation, its PoP and the challenge endpoint look like, and how they
5
+ * are presented at the token endpoint. It does not say how a workload gets an attestation from its
6
+ * attester. That part - where the attester's endpoints are listed, what the mint request looks like, how
7
+ * the instance key is proven, and what comes back - is an {@link AttesterProfile}.
8
+ *
9
+ * {@link ClientAttestation} and {@link AttestationIssuanceClient} use {@link PingFederateProfile} unless
10
+ * told otherwise, which is exactly the behaviour of earlier releases. See docs/attester-profiles.md.
11
+ */
12
+ import { ClientAttestationError } from "./errors.js";
13
+ import { signCompact } from "./signing-key-pair.js";
14
+ /** Everything a profile needs to mint one attestation. */
15
+ export class MintRequest {
16
+ /** The fresh key the attestation will be bound to (its `cnf`). */
17
+ instanceKey;
18
+ /** The workload's platform identity token (a JWT-SVID or equivalent). */
19
+ evidence;
20
+ /** The attester-issued challenge, if any. */
21
+ challenge;
22
+ /** Set by {@link AttestationIssuanceClient}; unset in the evidence-first one-call flow. */
23
+ clientId;
24
+ /** Optional RFC 9396 access request. */
25
+ authorizationDetails;
26
+ /** Optional operator object (who answers for this workload). */
27
+ operator;
28
+ constructor(init) {
29
+ this.instanceKey = init.instanceKey;
30
+ this.evidence = init.evidence;
31
+ this.challenge = init.challenge;
32
+ this.clientId = init.clientId;
33
+ this.authorizationDetails = init.authorizationDetails;
34
+ this.operator = init.operator;
35
+ }
36
+ /** Sign `claims` as a compact JWS with the instance key, under header `typ` (no `jwk`). */
37
+ sign(claims, typ) {
38
+ return signCompact(claims, this.instanceKey, typ, false);
39
+ }
40
+ /** A proof of possession of the instance key: `{aud, jti, iat[, challenge]}` signed with it. */
41
+ instanceProof(typ, audience) {
42
+ const claims = {
43
+ aud: audience,
44
+ jti: crypto.randomUUID(),
45
+ iat: Math.floor(Date.now() / 1000),
46
+ };
47
+ if (this.challenge)
48
+ claims.challenge = this.challenge;
49
+ return this.sign(claims, typ);
50
+ }
51
+ }
52
+ /**
53
+ * The PingFederate profile's discovery document path.
54
+ *
55
+ * @internal
56
+ */
57
+ export const WELL_KNOWN_PATH = "/.well-known/client-attester";
58
+ /** The instance-key proof typ of the PingFederate profile (server InstanceKeyProofValidator.TYP). */
59
+ export const INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt";
60
+ /**
61
+ * The attester this SDK was built against (PingFederate), and the default.
62
+ *
63
+ * - Discovery: `GET {attesterUrl}/.well-known/client-attester`, a JSON object with `evidence_audience`,
64
+ * `attestation_endpoint`, `challenge_endpoint`, `token_endpoint` and `pop_audience`. With no
65
+ * `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
66
+ * - Mint: `POST attestation_endpoint` with JSON `{[client_id,] instance_key, svid, proof
67
+ * [, authorization_details][, operator]}`, where `proof` is `{aud: evidence_audience, jti, iat
68
+ * [, challenge]}` signed by the instance key with `typ` `oauth-attestation-instance-proof+jwt`. The
69
+ * response is `{"attestation": ..., "expires_in": ...}`.
70
+ */
71
+ export class PingFederateProfile {
72
+ static WELL_KNOWN_PATH = WELL_KNOWN_PATH;
73
+ static INSTANCE_PROOF_TYP = INSTANCE_PROOF_TYP;
74
+ static DEFAULT_TOKEN_PATH = "/as/token.oauth2";
75
+ async discover(transport, attesterUrl) {
76
+ if (!attesterUrl)
77
+ throw new Error("attesterUrl is required for discovery");
78
+ const doc = await transport.requestJson("discover", "GET", attesterUrl + WELL_KNOWN_PATH);
79
+ const str = (key) => (typeof doc[key] === "string" && doc[key] ? doc[key] : undefined);
80
+ return {
81
+ evidenceAudience: str("evidence_audience"),
82
+ attestationEndpoint: str("attestation_endpoint"),
83
+ // token_endpoint is advertised by current attesters; fall back to the standard PF path so an older
84
+ // discovery document still works from the host alone.
85
+ tokenEndpoint: str("token_endpoint") ?? attesterUrl + PingFederateProfile.DEFAULT_TOKEN_PATH,
86
+ challengeEndpoint: str("challenge_endpoint"),
87
+ popAudience: str("pop_audience"),
88
+ document: doc,
89
+ };
90
+ }
91
+ async mint(transport, metadata, request) {
92
+ if (!metadata.attestationEndpoint) {
93
+ throw new ClientAttestationError("attester discovery document has no attestation_endpoint");
94
+ }
95
+ const proof = await request.instanceProof(INSTANCE_PROOF_TYP, metadata.evidenceAudience);
96
+ const body = {};
97
+ if (request.clientId)
98
+ body.client_id = request.clientId;
99
+ body.instance_key = request.instanceKey.publicJwk();
100
+ body.svid = request.evidence;
101
+ body.proof = proof;
102
+ if (request.authorizationDetails)
103
+ body.authorization_details = request.authorizationDetails;
104
+ // Asked for at mint time so the ATTESTER is what states the operator, rather than the client
105
+ // asserting it about itself at the token endpoint. An attester that does not know it ignores it.
106
+ if (request.operator)
107
+ body.operator = request.operator;
108
+ const response = await transport.requestJson("mint", "POST", metadata.attestationEndpoint, body);
109
+ const attestation = response.attestation;
110
+ if (typeof attestation !== "string" || !attestation) {
111
+ throw new ClientAttestationError("attester response contained no attestation", {
112
+ body: JSON.stringify(response),
113
+ });
114
+ }
115
+ const expiresIn = response.expires_in;
116
+ return { attestation, expiresIn: typeof expiresIn === "number" ? expiresIn : undefined };
117
+ }
118
+ }
119
+ /**
120
+ * The discovery-document view of metadata: the raw document, or the snake_case fields that are set.
121
+ *
122
+ * @internal
123
+ */
124
+ export function metadataDocument(metadata) {
125
+ if (metadata.document)
126
+ return metadata.document;
127
+ const doc = {};
128
+ const fields = [
129
+ ["evidence_audience", metadata.evidenceAudience],
130
+ ["attestation_endpoint", metadata.attestationEndpoint],
131
+ ["challenge_endpoint", metadata.challengeEndpoint],
132
+ ["token_endpoint", metadata.tokenEndpoint],
133
+ ["pop_audience", metadata.popAudience],
134
+ ];
135
+ for (const [key, value] of fields)
136
+ if (value)
137
+ doc[key] = value;
138
+ return doc;
139
+ }
@@ -1,4 +1,7 @@
1
- import type { JWK } from "jose";
1
+ /**
2
+ * Builders for the attestation, PoP and DPoP JWTs (draft-ietf-oauth-attestation-based-client-auth).
3
+ */
4
+ import { type JWK } from "jose";
2
5
  import { SigningKeyPair } from "./signing-key-pair.js";
3
6
  import { type JwsSigner } from "./signer.js";
4
7
  export declare const ATTESTATION_TYP = "oauth-client-attestation+jwt";
@@ -87,10 +90,3 @@ export declare class DpopProofBuilder {
87
90
  issuedAt(epochSeconds: number): this;
88
91
  build(): Promise<string>;
89
92
  }
90
- /** RFC 9449 `ath`: base64url(SHA-256(ASCII access token)). */
91
- export declare function accessTokenHash(accessToken: string): string;
92
- /**
93
- * The DPoP `htu` for a URL: scheme, host, port and path, with no query and no fragment. Scheme and host
94
- * are lower-cased and a default port dropped; a URL with no path keeps none (no slash is invented).
95
- */
96
- export declare function normalizeHtu(url: string): string;
package/dist/builders.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Builders for the attestation, PoP and DPoP JWTs (draft-ietf-oauth-attestation-based-client-auth).
3
3
  */
4
- import { createHash, randomUUID } from "node:crypto";
4
+ import { base64url } from "jose";
5
5
  import { SigningKeyPair, requireText, signCompact } from "./signing-key-pair.js";
6
+ import { utf8Encode } from "./runtime.js";
6
7
  import { signExternal } from "./signer.js";
7
8
  export const ATTESTATION_TYP = "oauth-client-attestation+jwt";
8
9
  export const POP_TYP = "oauth-client-attestation-pop+jwt";
@@ -172,7 +173,7 @@ export class PopBuilder {
172
173
  }
173
174
  const claims = {
174
175
  aud: this.#audience,
175
- jti: this.#jwtId ?? randomUUID(),
176
+ jti: this.#jwtId ?? crypto.randomUUID(),
176
177
  iat: this.#issuedAt !== null ? this.#issuedAt : nowSeconds(),
177
178
  };
178
179
  if (this.#clientId) {
@@ -193,7 +194,7 @@ export class DpopProofBuilder {
193
194
  #htm = "POST";
194
195
  #htu = null;
195
196
  #nonce = null;
196
- #ath = null;
197
+ #accessToken = null;
197
198
  #jwtId = null;
198
199
  #issuedAt = null;
199
200
  constructor(instanceKey) {
@@ -224,7 +225,7 @@ export class DpopProofBuilder {
224
225
  * Leave unset for a proof sent to the token endpoint.
225
226
  */
226
227
  accessToken(accessToken) {
227
- this.#ath = accessToken ? accessTokenHash(accessToken) : null;
228
+ this.#accessToken = accessToken || null;
228
229
  return this;
229
230
  }
230
231
  jwtId(jwtId) {
@@ -242,25 +243,32 @@ export class DpopProofBuilder {
242
243
  const claims = {
243
244
  htm: this.#htm.toUpperCase(),
244
245
  htu: normalizeHtu(this.#htu),
245
- jti: this.#jwtId ?? randomUUID(),
246
+ jti: this.#jwtId ?? crypto.randomUUID(),
246
247
  iat: this.#issuedAt !== null ? this.#issuedAt : nowSeconds(),
247
248
  };
248
249
  if (this.#nonce) {
249
250
  claims.nonce = this.#nonce;
250
251
  }
251
- if (this.#ath) {
252
- claims.ath = this.#ath;
252
+ if (this.#accessToken) {
253
+ claims.ath = await accessTokenHash(this.#accessToken);
253
254
  }
254
255
  return signCompact(claims, this.#instanceKey, DPOP_TYP, true);
255
256
  }
256
257
  }
257
- /** RFC 9449 `ath`: base64url(SHA-256(ASCII access token)). */
258
- export function accessTokenHash(accessToken) {
259
- return createHash("sha256").update(accessToken, "ascii").digest("base64url");
258
+ /**
259
+ * RFC 9449 `ath`: base64url(SHA-256(ASCII access token)).
260
+ *
261
+ * @internal
262
+ */
263
+ export async function accessTokenHash(accessToken) {
264
+ const digest = await crypto.subtle.digest("SHA-256", utf8Encode(accessToken));
265
+ return base64url.encode(new Uint8Array(digest));
260
266
  }
261
267
  /**
262
268
  * The DPoP `htu` for a URL: scheme, host, port and path, with no query and no fragment. Scheme and host
263
269
  * are lower-cased and a default port dropped; a URL with no path keeps none (no slash is invented).
270
+ *
271
+ * @internal
264
272
  */
265
273
  export function normalizeHtu(url) {
266
274
  const bare = url.split(/[?#]/, 1)[0];
@@ -9,10 +9,6 @@ export declare const ENV_SPIFFE_SOCKET = "SPIFFE_ENDPOINT_SOCKET";
9
9
  export declare const DEFAULT_TOKEN_FILE = "/var/run/secrets/tokens/attester-token";
10
10
  /** Default SPIRE agent socket path. */
11
11
  export declare const DEFAULT_SPIFFE_SOCKET = "/tmp/spire-agent/public/api.sock";
12
- /** GCP metadata server origin. */
13
- export declare const GCP_METADATA_BASE = "http://metadata.google.internal";
14
- /** GCP metadata server identity endpoint (returns a Google-signed ID token for the runtime SA). */
15
- export declare const GCP_METADATA_IDENTITY: string;
16
12
  /** No platform identity could be detected and none was supplied. */
17
13
  export declare class EvidenceNotFound extends Error {
18
14
  constructor(message: string);
@@ -23,6 +19,9 @@ export declare class EvidenceNotFound extends Error {
23
19
  * Kubernetes and GKE mount a short-lived, auto-rotated service-account token into the pod as a file
24
20
  * (a projected `serviceAccountToken` volume). Reading it fresh each time picks up the rotation. The
25
21
  * file's audience must already be the attester's evidence audience — set it when you declare the volume.
22
+ *
23
+ * Needs a filesystem: `node:fs/promises` is loaded on the first `fetch`, which throws a
24
+ * `ClientAttestationError` on a runtime without it.
26
25
  */
27
26
  export declare class FileTokenSvidSource implements SvidSource {
28
27
  private readonly path;
@@ -46,20 +45,6 @@ export declare class GoogleMetadataSvidSource implements SvidSource {
46
45
  constructor(timeoutMs?: number, identityUrl?: string);
47
46
  fetch(audience: string): Promise<string>;
48
47
  }
49
- /**
50
- * Internal overrides for {@link autoDetectEvidence}, so tests can point detection at fixtures instead
51
- * of the host's real paths and the real GCP metadata server. Not part of the supported API.
52
- *
53
- * @internal
54
- */
55
- export interface AutoDetectOptions {
56
- /** Path probed in place of {@link DEFAULT_TOKEN_FILE}. */
57
- defaultTokenFile?: string;
58
- /** Path probed in place of {@link DEFAULT_SPIFFE_SOCKET}. */
59
- defaultSpiffeSocket?: string;
60
- /** Origin used in place of {@link GCP_METADATA_BASE} for the probe and the identity call. */
61
- gcpMetadataBase?: string;
62
- }
63
48
  /**
64
49
  * Return the first evidence source that applies to this runtime, or throw with guidance.
65
50
  *
@@ -67,4 +52,4 @@ export interface AutoDetectOptions {
67
52
  * by an environment variable ({@link ENV_TOKEN_FILE}, {@link ENV_SPIFFE_SOCKET}) whose path does not
68
53
  * exist throws {@link EvidenceNotFound} naming the variable; it never falls through to the next source.
69
54
  */
70
- export declare function autoDetectEvidence(opts?: AutoDetectOptions): Promise<SvidSource>;
55
+ export declare function autoDetectEvidence(): Promise<SvidSource>;
package/dist/evidence.js CHANGED
@@ -10,13 +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(pfHost)` and have it work without naming the source. Override by
13
+ * can write `new ClientAttestation(attesterUrl)` and have it work without naming the source. Override by
14
14
  * passing an explicit {@link SvidSource} to {@link ClientAttestation}.
15
15
  */
16
- import { existsSync } from "node:fs";
17
- import { readFile } from "node:fs/promises";
18
16
  import { TransportError } from "./errors.js";
19
17
  import { request, snippet } from "./http.js";
18
+ import { envVar, importNodeBuiltin } from "./runtime.js";
20
19
  import { SpireWorkloadApiSvidSource, StaticSvidSource } from "./svid-source.js";
21
20
  /** Environment variable holding the evidence token itself (static). */
22
21
  export const ENV_SVID = "CLIENT_ATTESTATION_SVID";
@@ -28,9 +27,17 @@ export const ENV_SPIFFE_SOCKET = "SPIFFE_ENDPOINT_SOCKET";
28
27
  export const DEFAULT_TOKEN_FILE = "/var/run/secrets/tokens/attester-token";
29
28
  /** Default SPIRE agent socket path. */
30
29
  export const DEFAULT_SPIFFE_SOCKET = "/tmp/spire-agent/public/api.sock";
31
- /** GCP metadata server origin. */
30
+ /**
31
+ * GCP metadata server origin.
32
+ *
33
+ * @internal
34
+ */
32
35
  export const GCP_METADATA_BASE = "http://metadata.google.internal";
33
- /** GCP metadata server identity endpoint (returns a Google-signed ID token for the runtime SA). */
36
+ /**
37
+ * GCP metadata server identity endpoint (returns a Google-signed ID token for the runtime SA).
38
+ *
39
+ * @internal
40
+ */
34
41
  export const GCP_METADATA_IDENTITY = GCP_METADATA_BASE + "/computeMetadata/v1/instance/service-accounts/default/identity";
35
42
  /** No platform identity could be detected and none was supplied. */
36
43
  export class EvidenceNotFound extends Error {
@@ -45,6 +52,9 @@ export class EvidenceNotFound extends Error {
45
52
  * Kubernetes and GKE mount a short-lived, auto-rotated service-account token into the pod as a file
46
53
  * (a projected `serviceAccountToken` volume). Reading it fresh each time picks up the rotation. The
47
54
  * file's audience must already be the attester's evidence audience — set it when you declare the volume.
55
+ *
56
+ * Needs a filesystem: `node:fs/promises` is loaded on the first `fetch`, which throws a
57
+ * `ClientAttestationError` on a runtime without it.
48
58
  */
49
59
  export class FileTokenSvidSource {
50
60
  path;
@@ -52,6 +62,7 @@ export class FileTokenSvidSource {
52
62
  this.path = path;
53
63
  }
54
64
  async fetch(_audience) {
65
+ const { readFile } = await importNodeBuiltin("fs/promises", "FileTokenSvidSource");
55
66
  const contents = await readFile(this.path, "utf8");
56
67
  return contents.trim();
57
68
  }
@@ -90,6 +101,19 @@ export class GoogleMetadataSvidSource {
90
101
  return res.body.trim();
91
102
  }
92
103
  }
104
+ /**
105
+ * A path-existence check, or one that always answers `false` on a runtime with no filesystem (an edge
106
+ * runtime), where the file and socket sources cannot apply.
107
+ */
108
+ async function pathProbe() {
109
+ try {
110
+ const { existsSync } = await importNodeBuiltin("fs", "autoDetectEvidence");
111
+ return existsSync;
112
+ }
113
+ catch {
114
+ return () => false;
115
+ }
116
+ }
93
117
  async function gcpMetadataAvailable(base, timeoutMs = 300) {
94
118
  try {
95
119
  const res = await request(base + "/computeMetadata/v1/", {
@@ -103,45 +127,39 @@ async function gcpMetadataAvailable(base, timeoutMs = 300) {
103
127
  return false;
104
128
  }
105
129
  }
106
- /**
107
- * Return the first evidence source that applies to this runtime, or throw with guidance.
108
- *
109
- * Order: explicit env token, projected token file, SPIRE socket, GCP metadata server. A source named
110
- * by an environment variable ({@link ENV_TOKEN_FILE}, {@link ENV_SPIFFE_SOCKET}) whose path does not
111
- * exist throws {@link EvidenceNotFound} naming the variable; it never falls through to the next source.
112
- */
113
130
  export async function autoDetectEvidence(opts = {}) {
131
+ const exists = await pathProbe();
114
132
  const defaultTokenFile = opts.defaultTokenFile ?? DEFAULT_TOKEN_FILE;
115
133
  const defaultSocket = opts.defaultSpiffeSocket ?? DEFAULT_SPIFFE_SOCKET;
116
134
  const metadataBase = (opts.gcpMetadataBase ?? GCP_METADATA_BASE).replace(/\/+$/, "");
117
- const staticToken = process.env[ENV_SVID];
135
+ const staticToken = envVar(ENV_SVID);
118
136
  if (staticToken) {
119
137
  return new StaticSvidSource(staticToken.trim());
120
138
  }
121
139
  // An explicitly configured source that is missing is a misconfiguration: fail closed rather than
122
140
  // fall through to a different identity the operator did not choose.
123
- const tokenFile = process.env[ENV_TOKEN_FILE];
141
+ const tokenFile = envVar(ENV_TOKEN_FILE);
124
142
  if (tokenFile) {
125
- if (!existsSync(tokenFile)) {
143
+ if (!exists(tokenFile)) {
126
144
  throw new EvidenceNotFound(`${ENV_TOKEN_FILE} is set to ${tokenFile}, but no such file exists`);
127
145
  }
128
146
  return new FileTokenSvidSource(tokenFile);
129
147
  }
130
- if (existsSync(defaultTokenFile)) {
148
+ if (exists(defaultTokenFile)) {
131
149
  return new FileTokenSvidSource(defaultTokenFile);
132
150
  }
133
- const socket = process.env[ENV_SPIFFE_SOCKET];
151
+ const socket = envVar(ENV_SPIFFE_SOCKET);
134
152
  if (socket) {
135
153
  const socketPath = socket.replace(/^unix:\/\//, "");
136
- if (!existsSync(socketPath)) {
154
+ if (!exists(socketPath)) {
137
155
  throw new EvidenceNotFound(`${ENV_SPIFFE_SOCKET} is set to ${socket}, but no such socket exists`);
138
156
  }
139
157
  return new SpireWorkloadApiSvidSource(socketPath);
140
158
  }
141
- if (existsSync(defaultSocket)) {
159
+ if (exists(defaultSocket)) {
142
160
  return new SpireWorkloadApiSvidSource(defaultSocket);
143
161
  }
144
- if (await gcpMetadataAvailable(metadataBase)) {
162
+ if (await gcpMetadataAvailable(metadataBase, opts.gcpProbeTimeoutMs)) {
145
163
  return new GoogleMetadataSvidSource(5000, metadataBase + "/computeMetadata/v1/instance/service-accounts/default/identity");
146
164
  }
147
165
  throw new EvidenceNotFound("Could not auto-detect the workload's platform identity. Set one of " +
package/dist/http.d.ts CHANGED
@@ -1,41 +1 @@
1
- export declare const DEFAULT_MAX_BYTES: number;
2
- export declare const DEFAULT_TIMEOUT_MS = 20000;
3
- /** At most this many characters of a response body are carried in an error. */
4
- export declare const ERROR_BODY_CHARS = 512;
5
- export interface HttpRequestOptions {
6
- method?: string;
7
- headers?: Record<string, string>;
8
- body?: string;
9
- /** Whole-call timeout in milliseconds (connect, headers and body). Defaults to 20 s. */
10
- timeoutMs?: number;
11
- /** Largest response body accepted, in bytes. Defaults to 1 MiB. */
12
- maxBytes?: number;
13
- }
14
- export interface HttpResponse {
15
- method: string;
16
- url: string;
17
- status: number;
18
- ok: boolean;
19
- headers: Headers;
20
- body: string;
21
- }
22
- /** The first {@link ERROR_BODY_CHARS} characters of a body, for an error message. */
23
- export declare function snippet(body: string): string;
24
- /**
25
- * Make one HTTP request. Resolves with the status, headers and body text for any non-3xx response
26
- * (non-2xx included); rejects with a {@link TransportError} for a network failure, timeout, redirect or
27
- * oversized body.
28
- */
29
- export declare function request(url: string, opts?: HttpRequestOptions): Promise<HttpResponse>;
30
- /**
31
- * Parse `res.body` as a JSON object. Throws a {@link TransportError} for a non-2xx status (carrying the
32
- * status and at most 512 characters of the body) and for a body that is not a JSON object. An empty body
33
- * reads as `{}` when `allowEmpty` is set.
34
- */
35
- export declare function jsonOk<T = Record<string, unknown>>(res: HttpResponse, opts?: {
36
- allowEmpty?: boolean;
37
- }): T;
38
- /** Parse `res.body` as a JSON object regardless of status; a {@link TransportError} if it is not one. */
39
- export declare function parseJsonObject<T = Record<string, unknown>>(res: HttpResponse, opts?: {
40
- allowEmpty?: boolean;
41
- }): T;
1
+ export {};