@idpartners/client-attestation-sdk 0.0.0-stage → 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.
Files changed (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +142 -2
  3. package/dist/attester.d.ts +93 -0
  4. package/dist/attester.js +139 -0
  5. package/dist/builders.d.ts +92 -0
  6. package/dist/builders.js +284 -0
  7. package/dist/credential.d.ts +22 -0
  8. package/dist/credential.js +44 -0
  9. package/dist/errors.d.ts +29 -0
  10. package/dist/errors.js +28 -0
  11. package/dist/evidence.d.ts +55 -0
  12. package/dist/evidence.js +168 -0
  13. package/dist/http.d.ts +1 -0
  14. package/dist/http.js +183 -0
  15. package/dist/index.d.ts +26 -0
  16. package/dist/index.js +20 -0
  17. package/dist/issuance.d.ts +61 -0
  18. package/dist/issuance.js +117 -0
  19. package/dist/logging.d.ts +12 -0
  20. package/dist/logging.js +77 -0
  21. package/dist/runtime.d.ts +1 -0
  22. package/dist/runtime.js +64 -0
  23. package/dist/signer.d.ts +32 -0
  24. package/dist/signer.js +121 -0
  25. package/dist/signing-key-pair.d.ts +44 -0
  26. package/dist/signing-key-pair.js +130 -0
  27. package/dist/svid-source.d.ts +41 -0
  28. package/dist/svid-source.js +77 -0
  29. package/dist/token-source.d.ts +146 -0
  30. package/dist/token-source.js +510 -0
  31. package/dist/validator/config.d.ts +61 -0
  32. package/dist/validator/config.js +61 -0
  33. package/dist/validator/errors.d.ts +20 -0
  34. package/dist/validator/errors.js +18 -0
  35. package/dist/validator/index.d.ts +19 -0
  36. package/dist/validator/index.js +12 -0
  37. package/dist/validator/jwks.d.ts +2 -0
  38. package/dist/validator/jwks.js +150 -0
  39. package/dist/validator/resource.d.ts +37 -0
  40. package/dist/validator/resource.js +98 -0
  41. package/dist/validator/result.d.ts +17 -0
  42. package/dist/validator/result.js +28 -0
  43. package/dist/validator/strict.d.ts +1 -0
  44. package/dist/validator/strict.js +202 -0
  45. package/dist/validator/validator.d.ts +41 -0
  46. package/dist/validator/validator.js +240 -0
  47. package/package.json +56 -4
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Runtime-neutral helpers, so the package loads on edge runtimes (Cloudflare Workers, Vercel Edge,
3
+ * Deno Deploy) as well as Node. Nothing here, or anywhere else in `src`, imports a Node builtin
4
+ * statically: the few features that need one load it lazily through {@link importNodeBuiltin}.
5
+ * Not exported from the package index.
6
+ */
7
+ import { ClientAttestationError } from "./errors.js";
8
+ const textEncoder = new TextEncoder();
9
+ const textDecoder = new TextDecoder();
10
+ /**
11
+ * UTF-8 bytes of a string.
12
+ *
13
+ * @internal
14
+ */
15
+ export function utf8Encode(text) {
16
+ return textEncoder.encode(text);
17
+ }
18
+ /**
19
+ * A string from UTF-8 bytes; malformed sequences become U+FFFD, as `Buffer#toString` does.
20
+ *
21
+ * @internal
22
+ */
23
+ export function utf8Decode(bytes) {
24
+ return textDecoder.decode(bytes);
25
+ }
26
+ /**
27
+ * Standard (padded) base64 of some bytes.
28
+ *
29
+ * @internal
30
+ */
31
+ export function base64Encode(bytes) {
32
+ let binary = "";
33
+ for (let i = 0; i < bytes.length; i += 0x8000) {
34
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
35
+ }
36
+ return btoa(binary);
37
+ }
38
+ /**
39
+ * An environment variable, or `undefined` on a runtime with no `process.env` (most edge runtimes).
40
+ *
41
+ * @internal
42
+ */
43
+ export function envVar(name) {
44
+ const proc = globalThis.process;
45
+ return proc?.env?.[name];
46
+ }
47
+ /**
48
+ * Load a Node builtin (`node:<name>`) on first use. Throws a {@link ClientAttestationError} naming the
49
+ * feature when the runtime has no such module (an edge runtime without Node compatibility).
50
+ *
51
+ * The specifier is built at run time so bundlers for edge targets leave it alone instead of failing to
52
+ * resolve it.
53
+ *
54
+ * @internal
55
+ */
56
+ export async function importNodeBuiltin(name, feature) {
57
+ const specifier = "node:" + name;
58
+ try {
59
+ return (await import(/* @vite-ignore */ /* webpackIgnore: true */ specifier));
60
+ }
61
+ catch (err) {
62
+ throw new ClientAttestationError(`${feature} needs the Node.js module '${specifier}', which this runtime does not provide`, { cause: err });
63
+ }
64
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * External JWS signing — keys that live outside the process (a vault, HSM, or KMS).
3
+ *
4
+ * {@link SigningKeyPair} covers the local-key case; {@link OpenBaoTransitSigner} signs inside an
5
+ * OpenBao / HashiCorp Vault transit engine so the attester's private key never leaves it.
6
+ */
7
+ import { type JWK } from "jose";
8
+ /** A JWS signing capability whose private key may live outside the process. `sign` returns the RAW JWS
9
+ * signature over the signing input — for ECDSA the fixed-width `r||s`, not ASN.1/DER. */
10
+ export interface JwsSigner {
11
+ readonly algorithm: string;
12
+ readonly keyId: string;
13
+ publicJwk(): JWK;
14
+ sign(signingInput: Uint8Array): Promise<Uint8Array>;
15
+ }
16
+ /**
17
+ * A {@link JwsSigner} backed by an OpenBao / HashiCorp Vault transit engine: the attestation is signed
18
+ * inside the vault (`POST /v1/transit/sign/<key>` with `marshaling_algorithm=jws`, which returns the
19
+ * fixed-width `r||s`) and the private key never leaves it. `create` reads the key metadata to pin the
20
+ * latest version, derive the public JWK and compute its RFC 7638 `kid`. Fail-closed (vault errors throw).
21
+ *
22
+ * @experimental See docs/api.md: a client for a third-party API that authenticates with a static token only; its constructor will change when other auth methods are added.
23
+ */
24
+ export declare class OpenBaoTransitSigner implements JwsSigner {
25
+ #private;
26
+ readonly algorithm: string;
27
+ readonly keyId: string;
28
+ private constructor();
29
+ static create(baoAddr: string, token: string, keyName: string): Promise<OpenBaoTransitSigner>;
30
+ publicJwk(): JWK;
31
+ sign(signingInput: Uint8Array): Promise<Uint8Array>;
32
+ }
package/dist/signer.js ADDED
@@ -0,0 +1,121 @@
1
+ /**
2
+ * External JWS signing — keys that live outside the process (a vault, HSM, or KMS).
3
+ *
4
+ * {@link SigningKeyPair} covers the local-key case; {@link OpenBaoTransitSigner} signs inside an
5
+ * OpenBao / HashiCorp Vault transit engine so the attester's private key never leaves it.
6
+ */
7
+ import { base64url, calculateJwkThumbprint, exportJWK, importSPKI } from "jose";
8
+ import { TransportError } from "./errors.js";
9
+ import { base64Encode } from "./runtime.js";
10
+ import { parseJsonObject, request as httpRequest, snippet } from "./http.js";
11
+ /** Per-call timeout for the vault. */
12
+ const OPENBAO_TIMEOUT_MS = 5_000;
13
+ const b64url = (bytes) => base64url.encode(bytes);
14
+ /** Assemble a compact JWS whose signature is produced by an external signer (header carries `kid`; external
15
+ *
16
+ * @internal
17
+ * signers hold issuing keys, referenced by id, never an embedded jwk). */
18
+ export async function signExternal(claims, signer, typ) {
19
+ const enc = new TextEncoder();
20
+ const header = { alg: signer.algorithm, typ, kid: signer.keyId };
21
+ const signingInput = `${b64url(enc.encode(JSON.stringify(header)))}.${b64url(enc.encode(JSON.stringify(claims)))}`;
22
+ const signature = await signer.sign(enc.encode(signingInput));
23
+ return `${signingInput}.${b64url(signature)}`;
24
+ }
25
+ const KEY_TYPE_ALG = { "ecdsa-p256": "ES256", "ecdsa-p384": "ES384", "ecdsa-p521": "ES512" };
26
+ const ALG_HASH = { ES256: "sha2-256", ES384: "sha2-384", ES512: "sha2-512" };
27
+ /**
28
+ * A {@link JwsSigner} backed by an OpenBao / HashiCorp Vault transit engine: the attestation is signed
29
+ * inside the vault (`POST /v1/transit/sign/<key>` with `marshaling_algorithm=jws`, which returns the
30
+ * fixed-width `r||s`) and the private key never leaves it. `create` reads the key metadata to pin the
31
+ * latest version, derive the public JWK and compute its RFC 7638 `kid`. Fail-closed (vault errors throw).
32
+ *
33
+ * @experimental See docs/api.md: a client for a third-party API that authenticates with a static token only; its constructor will change when other auth methods are added.
34
+ */
35
+ export class OpenBaoTransitSigner {
36
+ algorithm;
37
+ keyId;
38
+ #base;
39
+ #token;
40
+ #keyName;
41
+ #hash;
42
+ #keyVersion;
43
+ #publicJwk;
44
+ constructor(base, token, keyName, algorithm, hash, keyVersion, publicJwk, keyId) {
45
+ this.#base = base;
46
+ this.#token = token;
47
+ this.#keyName = keyName;
48
+ this.algorithm = algorithm;
49
+ this.#hash = hash;
50
+ this.#keyVersion = keyVersion;
51
+ this.#publicJwk = publicJwk;
52
+ this.keyId = keyId;
53
+ }
54
+ static async create(baoAddr, token, keyName) {
55
+ const base = baoAddr.replace(/\/+$/, "");
56
+ const data = await request("GET", base, token, `/v1/transit/keys/${keyName}`);
57
+ const keyType = String(data.type);
58
+ const algorithm = KEY_TYPE_ALG[keyType];
59
+ if (!algorithm) {
60
+ throw new Error(`transit key '${keyName}' has unsupported type for JWS signing: ${keyType}`);
61
+ }
62
+ const keyVersion = Number(data.latest_version);
63
+ const keys = data.keys;
64
+ const pem = keys[String(keyVersion)].public_key;
65
+ const jwk = await exportJWK(await importSPKI(pem, algorithm, { extractable: true }));
66
+ const keyId = await calculateJwkThumbprint(jwk, "sha256");
67
+ const publicJwk = { ...jwk, kid: keyId, alg: algorithm };
68
+ return new OpenBaoTransitSigner(base, token, keyName, algorithm, ALG_HASH[algorithm], keyVersion, publicJwk, keyId);
69
+ }
70
+ publicJwk() {
71
+ return { ...this.#publicJwk };
72
+ }
73
+ async sign(signingInput) {
74
+ const body = JSON.stringify({
75
+ input: base64Encode(signingInput),
76
+ marshaling_algorithm: "jws",
77
+ hash_algorithm: this.#hash,
78
+ key_version: this.#keyVersion,
79
+ });
80
+ const data = await request("POST", this.#base, this.#token, `/v1/transit/sign/${this.#keyName}`, body);
81
+ const signature = data.signature;
82
+ if (!signature) {
83
+ throw new Error("transit sign response carried no signature");
84
+ }
85
+ const raw = signature.slice(signature.lastIndexOf(":") + 1); // envelope: vault:v<n>:<base64url(r||s)>
86
+ return base64url.decode(raw);
87
+ }
88
+ }
89
+ async function request(method, base, token, path, body) {
90
+ let response;
91
+ try {
92
+ response = await httpRequest(base + path, {
93
+ method,
94
+ headers: { "X-Vault-Token": token, ...(body ? { "Content-Type": "application/json" } : {}) },
95
+ body,
96
+ timeoutMs: OPENBAO_TIMEOUT_MS,
97
+ });
98
+ }
99
+ catch (exc) {
100
+ if (exc instanceof TransportError && (exc.kind === "network" || exc.kind === "timeout")) {
101
+ const why = exc.kind === "timeout"
102
+ ? `timed out after ${OPENBAO_TIMEOUT_MS} ms`
103
+ : exc.cause instanceof Error
104
+ ? exc.cause.message
105
+ : String(exc.cause);
106
+ throw new TransportError(`OpenBao unreachable at ${base}: ${why}`, exc.kind, { cause: exc });
107
+ }
108
+ throw exc;
109
+ }
110
+ if (response.status !== 200) {
111
+ throw new TransportError(`OpenBao returned HTTP ${response.status} for ${path}`, "status", {
112
+ status: response.status,
113
+ body: snippet(response.body),
114
+ });
115
+ }
116
+ const parsed = parseJsonObject(response);
117
+ if (!parsed.data || typeof parsed.data !== "object") {
118
+ throw new Error("OpenBao response carried no data");
119
+ }
120
+ return parsed.data;
121
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Signing keys for attestation-based client authentication.
3
+ */
4
+ import { type CryptoKey, type JWK } from "jose";
5
+ /**
6
+ * A signing key pair (public + private) with its JWS `alg` and RFC 7638 thumbprint `kid`.
7
+ *
8
+ * Used both as the client instance key — which signs PoP / DPoP proofs and is bound into an
9
+ * attestation's `cnf` — and as a Client Attester's issuing key.
10
+ *
11
+ * jose's crypto operations are async, so instances are created via the static async
12
+ * factories {@link SigningKeyPair.generate} and {@link SigningKeyPair.fromJwk}.
13
+ */
14
+ export declare class SigningKeyPair {
15
+ #private;
16
+ /** The JWS algorithm (e.g. `ES256`). */
17
+ readonly algorithm: string;
18
+ /** The RFC 7638 JWK thumbprint (SHA-256, base64url) — this key's `kid`. */
19
+ readonly keyId: string;
20
+ private constructor();
21
+ /**
22
+ * Generate a fresh key pair for the given JWS algorithm: `ES256` / `ES384` / `ES512`
23
+ * (EC P-256 / P-384 / P-521), or `RS256` / `RS384` / `RS512` / `PS256` / `PS384` / `PS512`
24
+ * (RSA 2048).
25
+ *
26
+ * The private key is not extractable unless `options.extractable` is true: nothing in the SDK needs
27
+ * to export it, so by default it can't leak through `exportJWK`. Pass `{ extractable: true }` only to
28
+ * persist the key yourself (then restore it with {@link SigningKeyPair.fromJwk}).
29
+ */
30
+ static generate(algorithm: string, options?: {
31
+ extractable?: boolean;
32
+ }): Promise<SigningKeyPair>;
33
+ /**
34
+ * Wrap an existing JWK (which must contain the private component `d`) for the given algorithm.
35
+ */
36
+ static fromJwk(jwk: JWK, algorithm: string): Promise<SigningKeyPair>;
37
+ /** The private CryptoKey — package-internal; used by the JWS signer. */
38
+ get privateKey(): CryptoKey;
39
+ /**
40
+ * The public-only JWK (including `kid`) as a fresh object — suitable as an attestation
41
+ * `cnf.jwk` or a DPoP header value (from which callers strip `kid`).
42
+ */
43
+ publicJwk(): JWK;
44
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Signing keys for attestation-based client authentication.
3
+ */
4
+ import { SignJWT, calculateJwkThumbprint, exportJWK, generateKeyPair, importJWK, } from "jose";
5
+ /**
6
+ * A signing key pair (public + private) with its JWS `alg` and RFC 7638 thumbprint `kid`.
7
+ *
8
+ * Used both as the client instance key — which signs PoP / DPoP proofs and is bound into an
9
+ * attestation's `cnf` — and as a Client Attester's issuing key.
10
+ *
11
+ * jose's crypto operations are async, so instances are created via the static async
12
+ * factories {@link SigningKeyPair.generate} and {@link SigningKeyPair.fromJwk}.
13
+ */
14
+ export class SigningKeyPair {
15
+ /** The JWS algorithm (e.g. `ES256`). */
16
+ algorithm;
17
+ /** The RFC 7638 JWK thumbprint (SHA-256, base64url) — this key's `kid`. */
18
+ keyId;
19
+ #privateKey;
20
+ #publicJwk;
21
+ constructor(privateKey, algorithm, publicJwk, keyId) {
22
+ this.#privateKey = privateKey;
23
+ this.algorithm = algorithm;
24
+ // Store the public JWK with its kid stamped, so publicJwk() hands out a copy including kid.
25
+ this.#publicJwk = { ...publicJwk, kid: keyId };
26
+ this.keyId = keyId;
27
+ }
28
+ /**
29
+ * Generate a fresh key pair for the given JWS algorithm: `ES256` / `ES384` / `ES512`
30
+ * (EC P-256 / P-384 / P-521), or `RS256` / `RS384` / `RS512` / `PS256` / `PS384` / `PS512`
31
+ * (RSA 2048).
32
+ *
33
+ * The private key is not extractable unless `options.extractable` is true: nothing in the SDK needs
34
+ * to export it, so by default it can't leak through `exportJWK`. Pass `{ extractable: true }` only to
35
+ * persist the key yourself (then restore it with {@link SigningKeyPair.fromJwk}).
36
+ */
37
+ static async generate(algorithm, options = {}) {
38
+ checkAlgorithm(algorithm);
39
+ const { privateKey, publicKey } = await generateKeyPair(algorithm, {
40
+ extractable: options.extractable ?? false,
41
+ ...(algorithm.startsWith("ES") ? {} : { modulusLength: RSA_MODULUS_BITS }),
42
+ });
43
+ const publicJwk = publicMembers(await exportJWK(publicKey));
44
+ const keyId = await calculateJwkThumbprint(publicJwk, "sha256");
45
+ return new SigningKeyPair(privateKey, algorithm, publicJwk, keyId);
46
+ }
47
+ /**
48
+ * Wrap an existing JWK (which must contain the private component `d`) for the given algorithm.
49
+ */
50
+ static async fromJwk(jwk, algorithm) {
51
+ if (!jwk.d) {
52
+ throw new Error("JWK does not contain a private key");
53
+ }
54
+ checkAlgorithm(algorithm);
55
+ const privateKey = (await importJWK(jwk, algorithm));
56
+ // Derive the public-only JWK from the supplied members (drop the private ones).
57
+ const publicJwk = publicMembers(jwk);
58
+ const keyId = await calculateJwkThumbprint(publicJwk, "sha256");
59
+ return new SigningKeyPair(privateKey, algorithm, publicJwk, keyId);
60
+ }
61
+ /** The private CryptoKey — package-internal; used by the JWS signer. */
62
+ get privateKey() {
63
+ return this.#privateKey;
64
+ }
65
+ /**
66
+ * The public-only JWK (including `kid`) as a fresh object — suitable as an attestation
67
+ * `cnf.jwk` or a DPoP header value (from which callers strip `kid`).
68
+ */
69
+ publicJwk() {
70
+ return { ...this.#publicJwk };
71
+ }
72
+ }
73
+ /**
74
+ * The JWS algorithms {@link SigningKeyPair} can generate and sign with.
75
+ *
76
+ * @internal
77
+ */
78
+ export const SUPPORTED_ALGORITHMS = [
79
+ "ES256", "ES384", "ES512",
80
+ "RS256", "RS384", "RS512",
81
+ "PS256", "PS384", "PS512",
82
+ ];
83
+ /** RSA modulus size for generated RS* / PS* keys. */
84
+ const RSA_MODULUS_BITS = 2048;
85
+ function checkAlgorithm(algorithm) {
86
+ if (!SUPPORTED_ALGORITHMS.includes(algorithm)) {
87
+ throw new Error(`unsupported signing algorithm: ${algorithm}`);
88
+ }
89
+ }
90
+ /** The public members of an EC or RSA JWK (no private parameters, no metadata). */
91
+ function publicMembers(jwk) {
92
+ return jwk.kty === "RSA"
93
+ ? { kty: jwk.kty, n: jwk.n, e: jwk.e }
94
+ : { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
95
+ }
96
+ /**
97
+ * RFC 7638 JWK thumbprint (SHA-256, base64url) over the required members only.
98
+ *
99
+ * @internal
100
+ */
101
+ export async function thumbprint(publicJwk) {
102
+ return calculateJwkThumbprint(publicJwk, "sha256");
103
+ }
104
+ /**
105
+ * Sign claims into a compact JWS with an explicit `typ` header, keyed by `key`.
106
+ *
107
+ * When `embedJwk` is set the public key travels in a `jwk` header (as DPoP requires); otherwise
108
+ * the key's thumbprint `kid` is set.
109
+ *
110
+ * @internal
111
+ */
112
+ export async function signCompact(claims, key, typ, embedJwk) {
113
+ const header = { alg: key.algorithm, typ };
114
+ if (embedJwk) {
115
+ const publicJwk = key.publicJwk();
116
+ delete publicJwk.kid;
117
+ header.jwk = publicJwk;
118
+ }
119
+ else {
120
+ header.kid = key.keyId;
121
+ }
122
+ return new SignJWT(claims).setProtectedHeader(header).sign(key.privateKey);
123
+ }
124
+ /** @internal */
125
+ export function requireText(value, field) {
126
+ if (value == null || String(value).trim() === "") {
127
+ throw new Error(`${field} is required`);
128
+ }
129
+ return value;
130
+ }
@@ -0,0 +1,41 @@
1
+ /** Anything that can produce a compact JWT-SVID for an audience. */
2
+ export interface SvidSource {
3
+ fetch(audience: string): Promise<string>;
4
+ }
5
+ /** A fixed, pre-fetched SVID — for tests, or when the caller already holds one. */
6
+ export declare class StaticSvidSource implements SvidSource {
7
+ private readonly svid;
8
+ constructor(svid: string);
9
+ fetch(_audience: string): Promise<string>;
10
+ }
11
+ /**
12
+ * GET a URL returning `{"svid":"<jwt>"}` — e.g. the demo harness `/dev/svid` endpoint.
13
+ *
14
+ * For development and testing only. It trusts whatever the URL returns and the endpoint has no caller
15
+ * authentication, so anyone who can reach it can obtain the workload's SVID. In production use a SPIRE,
16
+ * file or cloud source.
17
+ *
18
+ * @experimental See docs/api.md: a development and test source with no caller authentication.
19
+ */
20
+ export declare class HttpSvidSource implements SvidSource {
21
+ private readonly url;
22
+ constructor(url: string);
23
+ fetch(_audience: string): Promise<string>;
24
+ }
25
+ /**
26
+ * The default source: fetch a JWT-SVID from a real SPIRE agent's Workload API via the `spire-agent`
27
+ * CLI. Swap for a native Workload API client by implementing {@link SvidSource} if you prefer no
28
+ * subprocess.
29
+ *
30
+ * Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
31
+ * a `ClientAttestationError` on a runtime without it.
32
+ *
33
+ * @experimental See docs/api.md: it shells out to the spire-agent CLI, and its constructor will change
34
+ * when it moves to the Workload API socket.
35
+ */
36
+ export declare class SpireWorkloadApiSvidSource implements SvidSource {
37
+ private readonly socketPath;
38
+ private readonly spireAgentBin;
39
+ constructor(socketPath?: string, spireAgentBin?: string);
40
+ fetch(audience: string): Promise<string>;
41
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Pluggable sources for a workload's SPIFFE JWT-SVID — the infra-identity input to attestation
3
+ * bootstrapping ({@link AttestationIssuanceClient}). The default {@link SpireWorkloadApiSvidSource}
4
+ * fetches it from a real SPIRE agent; the others cover dev and tests.
5
+ */
6
+ import { jsonOk, request } from "./http.js";
7
+ import { importNodeBuiltin } from "./runtime.js";
8
+ /** Per-call timeout for {@link HttpSvidSource}. */
9
+ const HTTP_SVID_TIMEOUT_MS = 20_000;
10
+ /** A fixed, pre-fetched SVID — for tests, or when the caller already holds one. */
11
+ export class StaticSvidSource {
12
+ svid;
13
+ constructor(svid) {
14
+ this.svid = svid;
15
+ }
16
+ async fetch(_audience) {
17
+ return this.svid;
18
+ }
19
+ }
20
+ /**
21
+ * GET a URL returning `{"svid":"<jwt>"}` — e.g. the demo harness `/dev/svid` endpoint.
22
+ *
23
+ * For development and testing only. It trusts whatever the URL returns and the endpoint has no caller
24
+ * authentication, so anyone who can reach it can obtain the workload's SVID. In production use a SPIRE,
25
+ * file or cloud source.
26
+ *
27
+ * @experimental See docs/api.md: a development and test source with no caller authentication.
28
+ */
29
+ export class HttpSvidSource {
30
+ url;
31
+ constructor(url) {
32
+ this.url = url;
33
+ }
34
+ async fetch(_audience) {
35
+ const res = await request(this.url, { method: "GET", timeoutMs: HTTP_SVID_TIMEOUT_MS });
36
+ const body = jsonOk(res);
37
+ if (typeof body.svid !== "string" || !body.svid)
38
+ throw new Error(`no svid in response from ${this.url}`);
39
+ return body.svid;
40
+ }
41
+ }
42
+ /**
43
+ * The default source: fetch a JWT-SVID from a real SPIRE agent's Workload API via the `spire-agent`
44
+ * CLI. Swap for a native Workload API client by implementing {@link SvidSource} if you prefer no
45
+ * subprocess.
46
+ *
47
+ * Needs Node.js (it spawns a process): `node:child_process` is loaded on the first `fetch`, which throws
48
+ * a `ClientAttestationError` on a runtime without it.
49
+ *
50
+ * @experimental See docs/api.md: it shells out to the spire-agent CLI, and its constructor will change
51
+ * when it moves to the Workload API socket.
52
+ */
53
+ export class SpireWorkloadApiSvidSource {
54
+ socketPath;
55
+ spireAgentBin;
56
+ constructor(socketPath = "/tmp/spire-agent/public/api.sock", spireAgentBin = "spire-agent") {
57
+ this.socketPath = socketPath;
58
+ this.spireAgentBin = spireAgentBin;
59
+ }
60
+ async fetch(audience) {
61
+ const { execFile } = await importNodeBuiltin("child_process", "SpireWorkloadApiSvidSource");
62
+ const stdout = await new Promise((resolve, reject) => {
63
+ execFile(this.spireAgentBin, ["api", "fetch", "jwt", "-audience", audience, "-socketPath", this.socketPath], (err, out) => (err ? reject(err) : resolve(String(out))));
64
+ });
65
+ for (let line of stdout.split("\n")) {
66
+ line = line.trim();
67
+ if (line.startsWith("token(")) {
68
+ const idx = line.indexOf("):");
69
+ if (idx >= 0)
70
+ return line.slice(idx + 2).trim();
71
+ }
72
+ if ((line.match(/\./g) ?? []).length === 2)
73
+ return line;
74
+ }
75
+ throw new Error(`could not parse SVID from spire-agent output:\n${stdout}`);
76
+ }
77
+ }
@@ -0,0 +1,146 @@
1
+ import { type AttesterMetadata, type AttesterProfile } from "./attester.js";
2
+ import { type Logger } from "./logging.js";
3
+ import type { SvidSource } from "./svid-source.js";
4
+ export declare const ATTESTATION_HEADER = "OAuth-Client-Attestation";
5
+ export declare const POP_HEADER = "OAuth-Client-Attestation-PoP";
6
+ /** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
7
+ export declare const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
8
+ export { ClientAttestationError, TransportError } from "./errors.js";
9
+ /** The attester's discovery document (PingFederate: `/.well-known/client-attester`). */
10
+ export interface AttesterDiscovery {
11
+ [member: string]: unknown;
12
+ evidence_audience?: string;
13
+ attestation_endpoint?: string;
14
+ challenge_endpoint?: string;
15
+ token_endpoint?: string;
16
+ pop_audience?: string;
17
+ }
18
+ /** Options for {@link ClientAttestation}. */
19
+ export interface ClientAttestationOptions {
20
+ /**
21
+ * The attester's host, when the options object is the only argument. The positional argument wins
22
+ * when both are given.
23
+ */
24
+ attesterUrl?: string;
25
+ /**
26
+ * The attester profile that discovers the attester and mints the attestation. Default
27
+ * {@link PingFederateProfile}, the behaviour of every earlier release. See docs/attester-profiles.md.
28
+ */
29
+ profile?: AttesterProfile;
30
+ /**
31
+ * Static attester metadata (evidence audience, attestation and token endpoints, optionally challenge
32
+ * endpoint and PoP audience). When set, no discovery request is made; the profile still mints.
33
+ */
34
+ metadata?: AttesterMetadata;
35
+ /**
36
+ * How to get the workload's platform identity. Leave unset to auto-detect the runtime (env token,
37
+ * projected token file, SPIRE socket, or GCP metadata). Pass an {@link SvidSource} to override.
38
+ */
39
+ evidence?: SvidSource;
40
+ /**
41
+ * Optional RFC 9396 access request sent at mint time; the attester grants at most what the workload
42
+ * is entitled to.
43
+ */
44
+ authorizationDetails?: Array<Record<string, unknown>>;
45
+ /**
46
+ * The client's operator - who answers for this workload. Sent in the mint request body as
47
+ * `operator`, so the attester (not the client) is what states it; an attester that does not know
48
+ * this key ignores it, one that does can refuse to name an operator it cannot corroborate. When set,
49
+ * it is also repeated at the token endpoint as the form parameter `operator` (compact JSON, sorted
50
+ * keys) so an AS can populate RFC 8693 `may_act` from it without parsing the attestation; an AS that
51
+ * does not know the parameter ignores it. Unset (or empty) sends nothing.
52
+ */
53
+ operator?: Record<string, unknown>;
54
+ /** Instance-key signature algorithm (default `ES256`). */
55
+ algorithm?: string;
56
+ /** Refresh this long before the token expires (default 30s). */
57
+ expirySkewSeconds?: number;
58
+ /** Per-request HTTP timeout in milliseconds (default 20000). */
59
+ timeoutMs?: number;
60
+ /**
61
+ * How to prove possession of the instance key at the token endpoint: the dedicated
62
+ * `OAuth-Client-Attestation-PoP` JWT (`"attestation_pop_jwt"`, the default), or a `DPoP` proof
63
+ * carrying the same challenge in `nonce` (`"dpop_combined"`).
64
+ */
65
+ popMethod?: "attestation_pop_jwt" | "dpop_combined";
66
+ /**
67
+ * Whether the attester-issued challenge also rides in the token-endpoint proof (the PoP's
68
+ * `challenge` claim, or the DPoP `nonce`), on top of the mint-time instance proof it always
69
+ * protects. The default `true` binds token issuance to the same challenge as the mint, so a captured
70
+ * attestation cannot be redeemed for a token without it - but it requires an attester whose
71
+ * challenge store tolerates being checked at both points. Some attesters treat a challenge as
72
+ * consumed the moment the mint's instance proof uses it, and then reject a token request that
73
+ * presents the same value as "unknown or expired" - set this `false` against one of those (the
74
+ * challenge still protects the mint; only the token-endpoint copy is dropped).
75
+ */
76
+ challengeAtToken?: boolean;
77
+ /**
78
+ * When the attester advertises a `challenge_endpoint`, fail the flow if the challenge cannot be
79
+ * fetched (default `true`). Set `false` to proceed without a challenge instead.
80
+ */
81
+ requireChallenge?: boolean;
82
+ /**
83
+ * Allow plain `http` for the attester URL and the endpoints the attester advertises (default
84
+ * `false`). Loopback hosts (`localhost`, `127.0.0.1`, `::1`) are always allowed.
85
+ */
86
+ allowInsecure?: boolean;
87
+ /**
88
+ * Where to send log events (see docs/logging.md). Any object with `debug`, `info`, `warn` and
89
+ * `error(msg, attrs)` works, `console` included. Unset, nothing is logged. Tokens, proofs,
90
+ * attestations, SVIDs and key material are never logged.
91
+ */
92
+ logger?: Logger;
93
+ }
94
+ /**
95
+ * Gets and caches an access token using client attestation.
96
+ *
97
+ * `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
98
+ * discovery) or a custom `opts.profile`.
99
+ *
100
+ * @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
101
+ * `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
102
+ * is given.
103
+ */
104
+ export declare class ClientAttestation {
105
+ #private;
106
+ /** The normalised attester URL, or `""` when the client was built from static metadata alone. */
107
+ readonly baseUrl: string;
108
+ constructor(attesterUrl?: string | ClientAttestationOptions, opts?: ClientAttestationOptions);
109
+ /** Return a valid access token, minting a new one only when the cached one is near expiry. */
110
+ token(opts?: {
111
+ forceRefresh?: boolean;
112
+ }): Promise<string>;
113
+ /**
114
+ * `{ Authorization: "<scheme> <token>" }`. For a Bearer token that is all a resource needs. For a
115
+ * DPoP-bound token (`token_type` DPoP) the scheme is `DPoP` and every request ALSO needs a fresh
116
+ * DPoP proof — use {@link requestHeaders} (or {@link fetch}) instead, which adds it.
117
+ */
118
+ authorizationHeader(opts?: {
119
+ forceRefresh?: boolean;
120
+ }): Promise<Record<string, string>>;
121
+ /**
122
+ * The headers to send to a resource for one request, obtaining or refreshing the token as needed.
123
+ *
124
+ * - Bearer token: `{ Authorization: "Bearer <t>" }`
125
+ * - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
126
+ * token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
127
+ * url's origin returned, if any.
128
+ */
129
+ requestHeaders(method: string, url: string, opts?: {
130
+ forceRefresh?: boolean;
131
+ }): Promise<Record<string, string>>;
132
+ /**
133
+ * A `fetch` wrapper that attaches the token (refreshing it as needed) to the request. Same signature
134
+ * as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
135
+ * a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
136
+ * unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
137
+ */
138
+ fetch(input: string | URL | Request, init?: RequestInit): Promise<Response>;
139
+ /**
140
+ * The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
141
+ * then cached. With static `metadata`, the fields that were given, in the document's snake_case.
142
+ */
143
+ discovery(): Promise<AttesterDiscovery>;
144
+ /** The attester's endpoints: the static `metadata`, or what the profile discovered (once, then cached). */
145
+ attesterMetadata(): Promise<AttesterMetadata>;
146
+ }