@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,168 @@
1
+ /**
2
+ * Auto-detect the workload's platform identity (the evidence it presents to the attester).
3
+ *
4
+ * The workload proves *what it is* with a token its runtime already issues. Which token, and where it
5
+ * comes from, depends on the runtime:
6
+ *
7
+ * - an explicit token in an environment variable (any runtime that injects one),
8
+ * - a projected/mounted token file (Kubernetes, GKE),
9
+ * - a SPIRE Workload API socket (SPIRE / SPIFFE),
10
+ * - the GCP metadata server (GCE, GKE, Cloud Run, Vertex AI Agent Engine).
11
+ *
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
14
+ * passing an explicit {@link SvidSource} to {@link ClientAttestation}.
15
+ */
16
+ import { TransportError } from "./errors.js";
17
+ import { request, snippet } from "./http.js";
18
+ import { envVar, importNodeBuiltin } from "./runtime.js";
19
+ import { SpireWorkloadApiSvidSource, StaticSvidSource } from "./svid-source.js";
20
+ /** Environment variable holding the evidence token itself (static). */
21
+ export const ENV_SVID = "CLIENT_ATTESTATION_SVID";
22
+ /** Environment variable holding a path to a projected token file. */
23
+ export const ENV_TOKEN_FILE = "CLIENT_ATTESTATION_TOKEN_FILE";
24
+ /** SPIRE Workload API socket (standard SPIFFE var). */
25
+ export const ENV_SPIFFE_SOCKET = "SPIFFE_ENDPOINT_SOCKET";
26
+ /** Default projected-token path used by the Kubernetes/GKE serviceAccountToken volume convention. */
27
+ export const DEFAULT_TOKEN_FILE = "/var/run/secrets/tokens/attester-token";
28
+ /** Default SPIRE agent socket path. */
29
+ export const DEFAULT_SPIFFE_SOCKET = "/tmp/spire-agent/public/api.sock";
30
+ /**
31
+ * GCP metadata server origin.
32
+ *
33
+ * @internal
34
+ */
35
+ export const GCP_METADATA_BASE = "http://metadata.google.internal";
36
+ /**
37
+ * GCP metadata server identity endpoint (returns a Google-signed ID token for the runtime SA).
38
+ *
39
+ * @internal
40
+ */
41
+ export const GCP_METADATA_IDENTITY = GCP_METADATA_BASE + "/computeMetadata/v1/instance/service-accounts/default/identity";
42
+ /** No platform identity could be detected and none was supplied. */
43
+ export class EvidenceNotFound extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = "EvidenceNotFound";
47
+ }
48
+ }
49
+ /**
50
+ * Reads the evidence token from a file on every fetch.
51
+ *
52
+ * Kubernetes and GKE mount a short-lived, auto-rotated service-account token into the pod as a file
53
+ * (a projected `serviceAccountToken` volume). Reading it fresh each time picks up the rotation. The
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.
58
+ */
59
+ export class FileTokenSvidSource {
60
+ path;
61
+ constructor(path) {
62
+ this.path = path;
63
+ }
64
+ async fetch(_audience) {
65
+ const { readFile } = await importNodeBuiltin("fs/promises", "FileTokenSvidSource");
66
+ const contents = await readFile(this.path, "utf8");
67
+ return contents.trim();
68
+ }
69
+ }
70
+ /**
71
+ * Fetches a Google-signed ID token for the runtime service account from the GCP metadata server.
72
+ *
73
+ * Works on any Google runtime that exposes the metadata server (GCE, GKE, Cloud Run, Vertex AI Agent
74
+ * 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.
76
+ */
77
+ export class GoogleMetadataSvidSource {
78
+ timeoutMs;
79
+ identityUrl;
80
+ /**
81
+ * @param timeoutMs per-request timeout.
82
+ * @param identityUrl the identity endpoint; internal, for tests - defaults to the real metadata server.
83
+ */
84
+ constructor(timeoutMs = 5000, identityUrl = GCP_METADATA_IDENTITY) {
85
+ this.timeoutMs = timeoutMs;
86
+ this.identityUrl = identityUrl;
87
+ }
88
+ async fetch(audience) {
89
+ const url = `${this.identityUrl}?audience=${encodeURIComponent(audience)}&format=full`;
90
+ const res = await request(url, {
91
+ method: "GET",
92
+ headers: { "Metadata-Flavor": "Google" },
93
+ timeoutMs: this.timeoutMs,
94
+ });
95
+ if (!res.ok) {
96
+ throw new TransportError(`GCP metadata identity endpoint -> ${res.status}`, "status", {
97
+ status: res.status,
98
+ body: snippet(res.body),
99
+ });
100
+ }
101
+ return res.body.trim();
102
+ }
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
+ }
117
+ async function gcpMetadataAvailable(base, timeoutMs = 300) {
118
+ try {
119
+ const res = await request(base + "/computeMetadata/v1/", {
120
+ method: "GET",
121
+ headers: { "Metadata-Flavor": "Google" },
122
+ timeoutMs,
123
+ });
124
+ return res.ok;
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ export async function autoDetectEvidence(opts = {}) {
131
+ const exists = await pathProbe();
132
+ const defaultTokenFile = opts.defaultTokenFile ?? DEFAULT_TOKEN_FILE;
133
+ const defaultSocket = opts.defaultSpiffeSocket ?? DEFAULT_SPIFFE_SOCKET;
134
+ const metadataBase = (opts.gcpMetadataBase ?? GCP_METADATA_BASE).replace(/\/+$/, "");
135
+ const staticToken = envVar(ENV_SVID);
136
+ if (staticToken) {
137
+ return new StaticSvidSource(staticToken.trim());
138
+ }
139
+ // An explicitly configured source that is missing is a misconfiguration: fail closed rather than
140
+ // fall through to a different identity the operator did not choose.
141
+ const tokenFile = envVar(ENV_TOKEN_FILE);
142
+ if (tokenFile) {
143
+ if (!exists(tokenFile)) {
144
+ throw new EvidenceNotFound(`${ENV_TOKEN_FILE} is set to ${tokenFile}, but no such file exists`);
145
+ }
146
+ return new FileTokenSvidSource(tokenFile);
147
+ }
148
+ if (exists(defaultTokenFile)) {
149
+ return new FileTokenSvidSource(defaultTokenFile);
150
+ }
151
+ const socket = envVar(ENV_SPIFFE_SOCKET);
152
+ if (socket) {
153
+ const socketPath = socket.replace(/^unix:\/\//, "");
154
+ if (!exists(socketPath)) {
155
+ throw new EvidenceNotFound(`${ENV_SPIFFE_SOCKET} is set to ${socket}, but no such socket exists`);
156
+ }
157
+ return new SpireWorkloadApiSvidSource(socketPath);
158
+ }
159
+ if (exists(defaultSocket)) {
160
+ return new SpireWorkloadApiSvidSource(defaultSocket);
161
+ }
162
+ if (await gcpMetadataAvailable(metadataBase, opts.gcpProbeTimeoutMs)) {
163
+ return new GoogleMetadataSvidSource(5000, metadataBase + "/computeMetadata/v1/instance/service-accounts/default/identity");
164
+ }
165
+ throw new EvidenceNotFound("Could not auto-detect the workload's platform identity. Set one of " +
166
+ `${ENV_SVID} (the token), ${ENV_TOKEN_FILE} (a token file), or ${ENV_SPIFFE_SOCKET} (SPIRE socket), ` +
167
+ "or pass evidence=<SvidSource> to ClientAttestation explicitly.");
168
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1 @@
1
+ export {};
package/dist/http.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The one internal HTTP transport every outbound call in the SDK goes through. Not exported from the
3
+ * package index.
4
+ *
5
+ * Uniform behaviour, because these requests carry credentials (Authorization, DPoP, client attestation
6
+ * headers, X-Vault-Token, SVIDs and proofs in bodies):
7
+ *
8
+ * - every call has a timeout, covering the body read as well as the headers;
9
+ * - redirects are never followed — a 3xx is a {@link TransportError}, and the target is never contacted;
10
+ * - the response body is capped (default 1 MiB), by `content-length` and again while streaming;
11
+ * - {@link request} returns non-2xx responses as data; {@link jsonOk} turns them, and malformed JSON,
12
+ * into a {@link TransportError}.
13
+ */
14
+ import { TransportError } from "./errors.js";
15
+ import { utf8Decode, utf8Encode } from "./runtime.js";
16
+ /** @internal */
17
+ export const DEFAULT_MAX_BYTES = 1024 * 1024;
18
+ /** @internal */
19
+ export const DEFAULT_TIMEOUT_MS = 20_000;
20
+ /**
21
+ * At most this many characters of a response body are carried in an error.
22
+ *
23
+ * @internal
24
+ */
25
+ export const ERROR_BODY_CHARS = 512;
26
+ /**
27
+ * The first {@link ERROR_BODY_CHARS} characters of a body, for an error message.
28
+ *
29
+ * @internal
30
+ */
31
+ export function snippet(body) {
32
+ return body.length > ERROR_BODY_CHARS ? body.slice(0, ERROR_BODY_CHARS) : body;
33
+ }
34
+ function describe(err) {
35
+ return err instanceof Error ? err.message : String(err);
36
+ }
37
+ function isAbort(err) {
38
+ const name = err?.name;
39
+ return name === "TimeoutError" || name === "AbortError";
40
+ }
41
+ /**
42
+ * Make one HTTP request. Resolves with the status, headers and body text for any non-3xx response
43
+ * (non-2xx included); rejects with a {@link TransportError} for a network failure, timeout, redirect or
44
+ * oversized body.
45
+ *
46
+ * @internal
47
+ */
48
+ export async function request(url, opts = {}) {
49
+ const method = opts.method ?? "GET";
50
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
51
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
52
+ const where = `${method} ${url}`;
53
+ const timedOut = (cause) => new TransportError(`${where} timed out after ${timeoutMs} ms`, "timeout", { cause });
54
+ let res;
55
+ try {
56
+ res = await fetch(url, {
57
+ method,
58
+ headers: opts.headers,
59
+ body: opts.body,
60
+ redirect: "manual",
61
+ signal: AbortSignal.timeout(timeoutMs),
62
+ });
63
+ }
64
+ catch (err) {
65
+ if (isAbort(err))
66
+ throw timedOut(err);
67
+ throw new TransportError(`${where} failed: ${describe(err)}`, "network", { cause: err });
68
+ }
69
+ const status = res.status;
70
+ if (res.type === "opaqueredirect" || (status >= 300 && status < 400)) {
71
+ await discard(res);
72
+ throw new TransportError(`${where} answered with a redirect (HTTP ${status}); redirects are not followed`, "redirect", {
73
+ status,
74
+ });
75
+ }
76
+ const tooLarge = () => new TransportError(`${where} response is larger than ${maxBytes} bytes`, "too_large", { status });
77
+ const declared = Number(res.headers?.get("content-length") ?? NaN);
78
+ if (declared > maxBytes) {
79
+ await discard(res);
80
+ throw tooLarge();
81
+ }
82
+ let body;
83
+ try {
84
+ body = await readCapped(res, maxBytes, tooLarge);
85
+ }
86
+ catch (err) {
87
+ if (err instanceof TransportError)
88
+ throw err;
89
+ if (isAbort(err))
90
+ throw timedOut(err);
91
+ throw new TransportError(`${where} failed reading the response: ${describe(err)}`, "network", { cause: err });
92
+ }
93
+ return {
94
+ method,
95
+ url,
96
+ status,
97
+ ok: status >= 200 && status < 300,
98
+ headers: res.headers ?? new Headers(),
99
+ body,
100
+ };
101
+ }
102
+ async function discard(res) {
103
+ try {
104
+ await res.body?.cancel();
105
+ }
106
+ catch {
107
+ // nothing to do: the response is being refused anyway
108
+ }
109
+ }
110
+ async function readCapped(res, maxBytes, tooLarge) {
111
+ if (!res.body) {
112
+ // only reached by stand-in Response objects (tests); a real fetch always has a stream
113
+ const text = await res.text();
114
+ if (utf8Encode(text).byteLength > maxBytes)
115
+ throw tooLarge();
116
+ return text;
117
+ }
118
+ const reader = res.body.getReader();
119
+ const chunks = [];
120
+ let total = 0;
121
+ for (;;) {
122
+ const { done, value } = await reader.read();
123
+ if (done)
124
+ break;
125
+ total += value.byteLength;
126
+ if (total > maxBytes) {
127
+ await reader.cancel().catch(() => undefined);
128
+ throw tooLarge();
129
+ }
130
+ chunks.push(value);
131
+ }
132
+ const all = new Uint8Array(total);
133
+ let offset = 0;
134
+ for (const chunk of chunks) {
135
+ all.set(chunk, offset);
136
+ offset += chunk.byteLength;
137
+ }
138
+ return utf8Decode(all);
139
+ }
140
+ /**
141
+ * Parse `res.body` as a JSON object. Throws a {@link TransportError} for a non-2xx status (carrying the
142
+ * status and at most 512 characters of the body) and for a body that is not a JSON object. An empty body
143
+ * reads as `{}` when `allowEmpty` is set.
144
+ *
145
+ * @internal
146
+ */
147
+ export function jsonOk(res, opts = {}) {
148
+ if (!res.ok) {
149
+ const body = snippet(res.body);
150
+ throw new TransportError(`${res.method} ${res.url} returned HTTP ${res.status}: ${body}`, "status", {
151
+ status: res.status,
152
+ body,
153
+ });
154
+ }
155
+ return parseJsonObject(res, opts);
156
+ }
157
+ /**
158
+ * Parse `res.body` as a JSON object regardless of status; a {@link TransportError} if it is not one.
159
+ *
160
+ * @internal
161
+ */
162
+ export function parseJsonObject(res, opts = {}) {
163
+ if (opts.allowEmpty && res.body === "")
164
+ return {};
165
+ let parsed;
166
+ try {
167
+ parsed = JSON.parse(res.body);
168
+ }
169
+ catch (err) {
170
+ throw new TransportError(`${res.method} ${res.url} returned malformed JSON: ${describe(err)}`, "json", {
171
+ status: res.status,
172
+ body: snippet(res.body),
173
+ cause: err,
174
+ });
175
+ }
176
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
177
+ throw new TransportError(`${res.method} ${res.url} did not return a JSON object`, "json", {
178
+ status: res.status,
179
+ body: snippet(res.body),
180
+ });
181
+ }
182
+ return parsed;
183
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Client-side builder SDK for OAuth Attestation-Based Client Authentication.
3
+ *
4
+ * Everything exported here is the package's API; docs/api.md says which parts are Stable and which are
5
+ * Experimental. Nothing else under dist/ can be imported: package.json "exports" allows only this entry
6
+ * point and "./validator".
7
+ */
8
+ export { SigningKeyPair } from "./signing-key-pair.js";
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";
11
+ export { OpenBaoTransitSigner } from "./signer.js";
12
+ export type { JwsSigner } from "./signer.js";
13
+ export { AttestationIssuanceClient, IssuedAttestation, IssuanceError, INSTANCE_PROOF_TYP, } from "./issuance.js";
14
+ export { StaticSvidSource, HttpSvidSource, SpireWorkloadApiSvidSource, } from "./svid-source.js";
15
+ export type { SvidSource } from "./svid-source.js";
16
+ export { EvidenceNotFound, FileTokenSvidSource, GoogleMetadataSvidSource, autoDetectEvidence, ENV_SVID, ENV_TOKEN_FILE, ENV_SPIFFE_SOCKET, DEFAULT_TOKEN_FILE, DEFAULT_SPIFFE_SOCKET, } from "./evidence.js";
17
+ export { ClientAttestation, ClientAttestationError, TransportError, DEFAULT_EXPIRY_SKEW_SECONDS, } from "./token-source.js";
18
+ export type { AttesterDiscovery, ClientAttestationOptions } from "./token-source.js";
19
+ export { MintRequest, PingFederateProfile } from "./attester.js";
20
+ export type { AttesterMetadata, AttesterProfile, AttesterTransport, MintResult } from "./attester.js";
21
+ export type { TransportFailure } from "./errors.js";
22
+ export { NOOP_LOGGER } from "./logging.js";
23
+ export type { Logger, LogAttributes, LogLevel } from "./logging.js";
24
+ export declare const VERSION = "1.0.0";
25
+ /** draft-ietf-oauth-attestation-based-client-auth revision this SDK implements. */
26
+ export declare const DRAFT_REVISION = "10";
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Client-side builder SDK for OAuth Attestation-Based Client Authentication.
3
+ *
4
+ * Everything exported here is the package's API; docs/api.md says which parts are Stable and which are
5
+ * Experimental. Nothing else under dist/ can be imported: package.json "exports" allows only this entry
6
+ * point and "./validator".
7
+ */
8
+ export { SigningKeyPair } from "./signing-key-pair.js";
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";
11
+ export { OpenBaoTransitSigner } from "./signer.js";
12
+ export { AttestationIssuanceClient, IssuedAttestation, IssuanceError, INSTANCE_PROOF_TYP, } from "./issuance.js";
13
+ export { StaticSvidSource, HttpSvidSource, SpireWorkloadApiSvidSource, } from "./svid-source.js";
14
+ export { EvidenceNotFound, FileTokenSvidSource, GoogleMetadataSvidSource, autoDetectEvidence, ENV_SVID, ENV_TOKEN_FILE, ENV_SPIFFE_SOCKET, DEFAULT_TOKEN_FILE, DEFAULT_SPIFFE_SOCKET, } from "./evidence.js";
15
+ export { ClientAttestation, ClientAttestationError, TransportError, DEFAULT_EXPIRY_SKEW_SECONDS, } from "./token-source.js";
16
+ export { MintRequest, PingFederateProfile } from "./attester.js";
17
+ export { NOOP_LOGGER } from "./logging.js";
18
+ export const VERSION = "1.0.0";
19
+ /** draft-ietf-oauth-attestation-based-client-auth revision this SDK implements. */
20
+ export const DRAFT_REVISION = "10";
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Client for the Client Attestation **issuance** endpoint (`POST /federation/attestation`).
3
+ *
4
+ * The inverse of {@link ClientAttestationBuilder}: instead of building an attestation locally with an
5
+ * attester key, a workload proves its SPIFFE identity (a JWT-SVID) and the Authorization Server
6
+ * (PingFederate) validates it and **mints** a Client Attestation bound to the workload's own instance
7
+ * key — bootstrapping a client credential from infra identity, no pre-shared secret.
8
+ */
9
+ import { type AttesterProfile } from "./attester.js";
10
+ import { SigningKeyPair } from "./signing-key-pair.js";
11
+ import type { SvidSource } from "./svid-source.js";
12
+ /** The instance-key proof typ the PingFederate profile presents (server InstanceKeyProofValidator.TYP). */
13
+ export { INSTANCE_PROOF_TYP } from "./attester.js";
14
+ /**
15
+ * Thrown when the issuance endpoint rejects the request (non-2xx). Transport failures — unreachable,
16
+ * timeout, redirect, oversized or malformed response — are a {@link TransportError} instead.
17
+ */
18
+ export declare class IssuanceError extends Error {
19
+ readonly status: number;
20
+ readonly body: string;
21
+ constructor(status: number, body: string);
22
+ }
23
+ /** A minted Client Attestation plus the instance key it is bound to. */
24
+ export declare class IssuedAttestation {
25
+ readonly attestation: string;
26
+ readonly expiresIn: number | undefined;
27
+ readonly instanceKey: SigningKeyPair;
28
+ constructor(attestation: string, expiresIn: number | undefined, instanceKey: SigningKeyPair);
29
+ /** A fresh Client Attestation PoP for the token endpoint, signed by the bound instance key. */
30
+ pop(audience: string, clientId?: string, challenge?: string): Promise<string>;
31
+ /**
32
+ * A fresh DPoP proof (RFC 9449) for the token endpoint (attestation combined mode, PoP method
33
+ * `dpop_combined`), signed by the same bound instance key.
34
+ */
35
+ dpop(method: string, uri: string, challenge?: string): Promise<string>;
36
+ }
37
+ /**
38
+ * Bootstraps a Client Attestation for a workload from its SPIFFE JWT-SVID.
39
+ *
40
+ * `opts.profile` sets the mint protocol (default {@link PingFederateProfile}; see
41
+ * docs/attester-profiles.md). The endpoint is given directly, so the profile's discovery is not used.
42
+ *
43
+ * @experimental See docs/api.md: it calls a PingFederate demo issuance servlet (/federation/attestation)
44
+ * whose protocol is not standardised.
45
+ */
46
+ export declare class AttestationIssuanceClient {
47
+ readonly endpoint: string;
48
+ readonly clientId: string;
49
+ readonly attesterIssuer: string;
50
+ private readonly source;
51
+ readonly instanceKey: SigningKeyPair;
52
+ readonly profile: AttesterProfile;
53
+ private constructor();
54
+ /** Generate a fresh ES256 instance key and return a ready client. */
55
+ static create(endpoint: string, clientId: string, attesterIssuer: string, source: SvidSource, opts?: {
56
+ instanceKey?: SigningKeyPair;
57
+ profile?: AttesterProfile;
58
+ }): Promise<AttestationIssuanceClient>;
59
+ /** Fetch the SVID, prove possession of the instance key, and exchange them for a minted attestation. */
60
+ bootstrap(authorizationDetails?: Array<Record<string, unknown>>, challenge?: string): Promise<IssuedAttestation>;
61
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Client for the Client Attestation **issuance** endpoint (`POST /federation/attestation`).
3
+ *
4
+ * The inverse of {@link ClientAttestationBuilder}: instead of building an attestation locally with an
5
+ * attester key, a workload proves its SPIFFE identity (a JWT-SVID) and the Authorization Server
6
+ * (PingFederate) validates it and **mints** a Client Attestation bound to the workload's own instance
7
+ * key — bootstrapping a client credential from infra identity, no pre-shared secret.
8
+ */
9
+ import { MintRequest, PingFederateProfile } from "./attester.js";
10
+ import { DpopProofBuilder, PopBuilder } from "./builders.js";
11
+ import { jsonOk, request } from "./http.js";
12
+ import { SigningKeyPair } from "./signing-key-pair.js";
13
+ /** The instance-key proof typ the PingFederate profile presents (server InstanceKeyProofValidator.TYP). */
14
+ export { INSTANCE_PROOF_TYP } from "./attester.js";
15
+ /** Per-call timeout for the issuance endpoint. */
16
+ const ISSUANCE_TIMEOUT_MS = 20_000;
17
+ /**
18
+ * Thrown when the issuance endpoint rejects the request (non-2xx). Transport failures — unreachable,
19
+ * timeout, redirect, oversized or malformed response — are a {@link TransportError} instead.
20
+ */
21
+ export class IssuanceError extends Error {
22
+ status;
23
+ body;
24
+ constructor(status, body) {
25
+ super(`attestation issuance failed: HTTP ${status}: ${body}`);
26
+ this.status = status;
27
+ this.body = body;
28
+ this.name = "IssuanceError";
29
+ }
30
+ }
31
+ /** A minted Client Attestation plus the instance key it is bound to. */
32
+ export class IssuedAttestation {
33
+ attestation;
34
+ expiresIn;
35
+ instanceKey;
36
+ constructor(attestation, expiresIn, instanceKey) {
37
+ this.attestation = attestation;
38
+ this.expiresIn = expiresIn;
39
+ this.instanceKey = instanceKey;
40
+ }
41
+ /** A fresh Client Attestation PoP for the token endpoint, signed by the bound instance key. */
42
+ async pop(audience, clientId, challenge) {
43
+ const builder = new PopBuilder(this.instanceKey).audience(audience);
44
+ if (clientId)
45
+ builder.clientId(clientId);
46
+ if (challenge)
47
+ builder.challenge(challenge);
48
+ return builder.build();
49
+ }
50
+ /**
51
+ * A fresh DPoP proof (RFC 9449) for the token endpoint (attestation combined mode, PoP method
52
+ * `dpop_combined`), signed by the same bound instance key.
53
+ */
54
+ async dpop(method, uri, challenge) {
55
+ const builder = new DpopProofBuilder(this.instanceKey).method(method).uri(uri);
56
+ if (challenge)
57
+ builder.nonce(challenge);
58
+ return builder.build();
59
+ }
60
+ }
61
+ /**
62
+ * Bootstraps a Client Attestation for a workload from its SPIFFE JWT-SVID.
63
+ *
64
+ * `opts.profile` sets the mint protocol (default {@link PingFederateProfile}; see
65
+ * docs/attester-profiles.md). The endpoint is given directly, so the profile's discovery is not used.
66
+ *
67
+ * @experimental See docs/api.md: it calls a PingFederate demo issuance servlet (/federation/attestation)
68
+ * whose protocol is not standardised.
69
+ */
70
+ export class AttestationIssuanceClient {
71
+ endpoint;
72
+ clientId;
73
+ attesterIssuer;
74
+ source;
75
+ instanceKey;
76
+ profile;
77
+ constructor(endpoint, clientId, attesterIssuer, source, instanceKey, profile) {
78
+ this.endpoint = endpoint;
79
+ this.clientId = clientId;
80
+ this.attesterIssuer = attesterIssuer;
81
+ this.source = source;
82
+ this.instanceKey = instanceKey;
83
+ this.profile = profile;
84
+ }
85
+ /** Generate a fresh ES256 instance key and return a ready client. */
86
+ static async create(endpoint, clientId, attesterIssuer, source, opts) {
87
+ const key = opts?.instanceKey ?? (await SigningKeyPair.generate("ES256"));
88
+ const profile = opts?.profile ?? new PingFederateProfile();
89
+ return new AttestationIssuanceClient(endpoint.replace(/\/$/, ""), clientId, attesterIssuer, source, key, profile);
90
+ }
91
+ /** Fetch the SVID, prove possession of the instance key, and exchange them for a minted attestation. */
92
+ async bootstrap(authorizationDetails, challenge) {
93
+ const svid = await this.source.fetch(this.attesterIssuer);
94
+ const minted = await this.profile.mint(issuanceTransport, { evidenceAudience: this.attesterIssuer, attestationEndpoint: this.endpoint }, new MintRequest({
95
+ instanceKey: this.instanceKey,
96
+ evidence: svid,
97
+ challenge: challenge || undefined,
98
+ clientId: this.clientId,
99
+ authorizationDetails,
100
+ }));
101
+ return new IssuedAttestation(minted.attestation, minted.expiresIn, this.instanceKey);
102
+ }
103
+ }
104
+ /** The issuance endpoint's transport: a non-2xx answer is an {@link IssuanceError}. */
105
+ const issuanceTransport = {
106
+ async requestJson(_step, method, url, body) {
107
+ const res = await request(url, {
108
+ method,
109
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
110
+ body: body === undefined ? undefined : JSON.stringify(body),
111
+ timeoutMs: ISSUANCE_TIMEOUT_MS,
112
+ });
113
+ if (!res.ok)
114
+ throw new IssuanceError(res.status, res.body);
115
+ return jsonOk(res);
116
+ },
117
+ };
@@ -0,0 +1,12 @@
1
+ /** Attributes carried with an event. */
2
+ export type LogAttributes = Record<string, unknown>;
3
+ /** Anything with these four methods: `console` fits, as do pino, winston and bunyan wrappers. */
4
+ export interface Logger {
5
+ debug(msg: string, attrs?: LogAttributes): void;
6
+ info(msg: string, attrs?: LogAttributes): void;
7
+ warn(msg: string, attrs?: LogAttributes): void;
8
+ error(msg: string, attrs?: LogAttributes): void;
9
+ }
10
+ export type LogLevel = "debug" | "info" | "warn" | "error";
11
+ /** The default: drops everything. */
12
+ export declare const NOOP_LOGGER: Logger;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Optional logging. The SDK is silent unless a {@link Logger} is passed in (`logger` on
3
+ * `ClientAttestation` and on `AccessTokenValidator`). Event names and attribute keys are shared with the
4
+ * other ports; see docs/logging.md.
5
+ *
6
+ * Never logged: tokens, attestations, proofs, SVIDs, challenge values, key material, header values, or
7
+ * URLs with their query string. As a backstop, any string attribute containing `eyJ` (how a JWT's
8
+ * base64url-encoded header begins) is replaced with `<redacted:N bytes>` before it reaches the logger.
9
+ */
10
+ import { TransportError } from "./errors.js";
11
+ import { utf8Encode } from "./runtime.js";
12
+ const noop = () => undefined;
13
+ /** The default: drops everything. */
14
+ export const NOOP_LOGGER = { debug: noop, info: noop, warn: noop, error: noop };
15
+ function guard(value) {
16
+ if (typeof value !== "string")
17
+ return value;
18
+ // A JWT's header and claims (any base64url-encoded JSON object) begin with "eyJ".
19
+ if (value.includes("eyJ")) {
20
+ return `<redacted:${utf8Encode(value).length} bytes>`;
21
+ }
22
+ return value;
23
+ }
24
+ /**
25
+ * Emit one event: undefined attributes dropped, JWT-looking values redacted. Never throws.
26
+ *
27
+ * @internal
28
+ */
29
+ export function emit(logger, level, event, attrs = {}) {
30
+ if (logger === NOOP_LOGGER)
31
+ return;
32
+ const clean = {};
33
+ for (const [key, value] of Object.entries(attrs)) {
34
+ if (value !== undefined && value !== null)
35
+ clean[key] = guard(value);
36
+ }
37
+ try {
38
+ logger[level](event, clean);
39
+ }
40
+ catch {
41
+ // a broken logger must never break the flow
42
+ }
43
+ }
44
+ /**
45
+ * `host[:port]` and the path of a URL, never its query string or fragment.
46
+ *
47
+ * @internal
48
+ */
49
+ export function urlParts(url) {
50
+ try {
51
+ const parsed = new URL(url);
52
+ return { host: parsed.host, path: parsed.pathname };
53
+ }
54
+ catch {
55
+ return {};
56
+ }
57
+ }
58
+ /**
59
+ * `host[:port]` of a URL.
60
+ *
61
+ * @internal
62
+ */
63
+ export function hostOf(url) {
64
+ return urlParts(url).host;
65
+ }
66
+ /**
67
+ * The `reason` attribute for a failed outbound call.
68
+ *
69
+ * @internal
70
+ */
71
+ export function failureReason(err) {
72
+ if (err instanceof TransportError)
73
+ return err.kind === "json" ? "malformed" : err.kind;
74
+ if (err instanceof SyntaxError)
75
+ return "malformed";
76
+ return "network";
77
+ }
@@ -0,0 +1 @@
1
+ export {};