@idpartners/client-attestation-sdk 0.0.0-stage → 0.2.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/LICENSE +202 -0
- package/README.md +111 -2
- package/dist/builders.d.ts +96 -0
- package/dist/builders.js +276 -0
- package/dist/credential.d.ts +22 -0
- package/dist/credential.js +44 -0
- package/dist/errors.d.ts +29 -0
- package/dist/errors.js +28 -0
- package/dist/evidence.d.ts +70 -0
- package/dist/evidence.js +150 -0
- package/dist/http.d.ts +41 -0
- package/dist/http.js +158 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +14 -0
- package/dist/issuance.d.ts +42 -0
- package/dist/issuance.js +110 -0
- package/dist/signer.d.ts +33 -0
- package/dist/signer.js +116 -0
- package/dist/signing-key-pair.d.ts +50 -0
- package/dist/signing-key-pair.js +115 -0
- package/dist/svid-source.d.ts +27 -0
- package/dist/svid-source.js +64 -0
- package/dist/token-source.d.ts +120 -0
- package/dist/token-source.js +424 -0
- package/dist/validator/config.d.ts +64 -0
- package/dist/validator/config.js +44 -0
- package/dist/validator/errors.d.ts +20 -0
- package/dist/validator/errors.js +18 -0
- package/dist/validator/index.d.ts +18 -0
- package/dist/validator/index.js +12 -0
- package/dist/validator/jwks.d.ts +39 -0
- package/dist/validator/jwks.js +135 -0
- package/dist/validator/resource.d.ts +42 -0
- package/dist/validator/resource.js +96 -0
- package/dist/validator/result.d.ts +19 -0
- package/dist/validator/result.js +26 -0
- package/dist/validator/validator.d.ts +33 -0
- package/dist/validator/validator.js +197 -0
- package/package.json +53 -4
package/dist/builders.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builders for the attestation, PoP and DPoP JWTs (draft-ietf-oauth-attestation-based-client-auth).
|
|
3
|
+
*/
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { SigningKeyPair, requireText, signCompact } from "./signing-key-pair.js";
|
|
6
|
+
import { signExternal } from "./signer.js";
|
|
7
|
+
export const ATTESTATION_TYP = "oauth-client-attestation+jwt";
|
|
8
|
+
export const POP_TYP = "oauth-client-attestation-pop+jwt";
|
|
9
|
+
export const DPOP_TYP = "dpop+jwt";
|
|
10
|
+
function nowSeconds() {
|
|
11
|
+
return Math.floor(Date.now() / 1000);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Builds a Client Attestation JWT — the credential a Client Attester issues to name a client
|
|
15
|
+
* (`sub`) and bind its instance key via `cnf.jwk`. Attester side; sign with the attester's key.
|
|
16
|
+
*/
|
|
17
|
+
export class ClientAttestationBuilder {
|
|
18
|
+
#attesterKey;
|
|
19
|
+
#issuer;
|
|
20
|
+
#clientId = null;
|
|
21
|
+
#cnfJwk = null;
|
|
22
|
+
#issuedAt = null;
|
|
23
|
+
#expiresAt = null;
|
|
24
|
+
#ttl = null;
|
|
25
|
+
#authorizationDetails = null;
|
|
26
|
+
#workload = null;
|
|
27
|
+
#operator = null;
|
|
28
|
+
constructor(attesterKey, issuer) {
|
|
29
|
+
this.#attesterKey = attesterKey;
|
|
30
|
+
this.#issuer = requireText(issuer, "issuer");
|
|
31
|
+
}
|
|
32
|
+
/** The client being attested — becomes the attestation `sub` (= `client_id`). */
|
|
33
|
+
clientId(clientId) {
|
|
34
|
+
this.#clientId = clientId;
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
/** Binds the client instance key as `cnf.jwk`. Must be a public JWK. */
|
|
38
|
+
confirmationJwk(publicInstanceJwk) {
|
|
39
|
+
this.#cnfJwk = publicInstanceJwk;
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
/** Convenience: binds the public half of the given instance key as `cnf.jwk`. */
|
|
43
|
+
confirmationKey(instanceKey) {
|
|
44
|
+
return this.confirmationJwk(instanceKey.publicJwk());
|
|
45
|
+
}
|
|
46
|
+
issuedAt(epochSeconds) {
|
|
47
|
+
this.#issuedAt = epochSeconds;
|
|
48
|
+
return this;
|
|
49
|
+
}
|
|
50
|
+
/** Sets an absolute `exp`. */
|
|
51
|
+
expiresAt(epochSeconds) {
|
|
52
|
+
this.#expiresAt = epochSeconds;
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
/** Sets `exp` to `iat + seconds`. */
|
|
56
|
+
expiresIn(seconds) {
|
|
57
|
+
this.#ttl = seconds;
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
/** The optional RFC 9396 `authorization_details` entitlement the attester asserts. */
|
|
61
|
+
authorizationDetails(details) {
|
|
62
|
+
this.#authorizationDetails = details;
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
/** Optional attester-asserted `workload` attributes. */
|
|
66
|
+
workload(workload) {
|
|
67
|
+
this.#workload = workload;
|
|
68
|
+
return this;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Names the client's OPERATOR — the human or organisation accountable for running this client,
|
|
72
|
+
* as distinct from the client itself (`sub`) and from the principal it acts for.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately a top-level claim rather than a member of `workload`: `workload` describes the
|
|
75
|
+
* running software, whereas the operator is an accountability statement about who answers for
|
|
76
|
+
* it. It is per-client and static, so it does not affect how far an attestation can be cached.
|
|
77
|
+
*
|
|
78
|
+
* The shape mirrors RFC 8693 `may_act` (`{ sub: ..., iss: ... }`) on purpose. An AS that wants
|
|
79
|
+
* the operator to become the party authorised to act for the subject can lift this straight
|
|
80
|
+
* into `may_act`; `iss` names the OP the operator's `sub` is meaningful in, so the identifier is
|
|
81
|
+
* not ambiguous across directories.
|
|
82
|
+
*
|
|
83
|
+
* This is an ASSERTION by the attester, not proof the operator authorised anything. A relying
|
|
84
|
+
* AS should treat it as it treats the rest of the attestation: trusted exactly as far as the
|
|
85
|
+
* attester is.
|
|
86
|
+
*/
|
|
87
|
+
operator(sub, issuer) {
|
|
88
|
+
const operator = { sub: requireText(sub, "operator sub") };
|
|
89
|
+
if (issuer) {
|
|
90
|
+
operator.iss = issuer;
|
|
91
|
+
}
|
|
92
|
+
this.#operator = operator;
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
async build() {
|
|
96
|
+
const sub = requireText(this.#clientId, "client_id");
|
|
97
|
+
if (this.#cnfJwk === null) {
|
|
98
|
+
throw new Error("confirmation key (cnf.jwk) is required");
|
|
99
|
+
}
|
|
100
|
+
const iat = this.#issuedAt !== null ? this.#issuedAt : nowSeconds();
|
|
101
|
+
const exp = this.#resolveExpiry(iat);
|
|
102
|
+
const claims = {
|
|
103
|
+
iss: this.#issuer,
|
|
104
|
+
sub,
|
|
105
|
+
iat,
|
|
106
|
+
exp,
|
|
107
|
+
cnf: { jwk: this.#cnfJwk },
|
|
108
|
+
};
|
|
109
|
+
if (this.#authorizationDetails && this.#authorizationDetails.length > 0) {
|
|
110
|
+
claims.authorization_details = this.#authorizationDetails;
|
|
111
|
+
}
|
|
112
|
+
if (this.#workload && Object.keys(this.#workload).length > 0) {
|
|
113
|
+
claims.workload = this.#workload;
|
|
114
|
+
}
|
|
115
|
+
if (this.#operator) {
|
|
116
|
+
claims.operator = this.#operator;
|
|
117
|
+
}
|
|
118
|
+
if (this.#attesterKey instanceof SigningKeyPair) {
|
|
119
|
+
return signCompact(claims, this.#attesterKey, ATTESTATION_TYP, false);
|
|
120
|
+
}
|
|
121
|
+
return signExternal(claims, this.#attesterKey, ATTESTATION_TYP);
|
|
122
|
+
}
|
|
123
|
+
#resolveExpiry(iat) {
|
|
124
|
+
if (this.#expiresAt !== null) {
|
|
125
|
+
return this.#expiresAt;
|
|
126
|
+
}
|
|
127
|
+
if (this.#ttl !== null) {
|
|
128
|
+
return iat + this.#ttl;
|
|
129
|
+
}
|
|
130
|
+
throw new Error("expiry is required: call expiresAt(...) or expiresIn(...)");
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Builds a Client Attestation PoP JWT proving possession of the instance key. Client side of
|
|
135
|
+
* `attest_jwt_client_auth`; mint a fresh one per token request.
|
|
136
|
+
*/
|
|
137
|
+
export class PopBuilder {
|
|
138
|
+
#instanceKey;
|
|
139
|
+
#clientId = null;
|
|
140
|
+
#audience = null;
|
|
141
|
+
#challenge = null;
|
|
142
|
+
#jwtId = null;
|
|
143
|
+
#issuedAt = null;
|
|
144
|
+
constructor(instanceKey) {
|
|
145
|
+
this.#instanceKey = instanceKey;
|
|
146
|
+
}
|
|
147
|
+
/** Sets the optional `iss`; when present the verifier requires it to equal the attestation `sub`. */
|
|
148
|
+
clientId(clientId) {
|
|
149
|
+
this.#clientId = clientId;
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
/** The AS identifier this PoP is bound to (`aud`) — required. */
|
|
153
|
+
audience(audience) {
|
|
154
|
+
this.#audience = audience;
|
|
155
|
+
return this;
|
|
156
|
+
}
|
|
157
|
+
challenge(challenge) {
|
|
158
|
+
this.#challenge = challenge;
|
|
159
|
+
return this;
|
|
160
|
+
}
|
|
161
|
+
jwtId(jwtId) {
|
|
162
|
+
this.#jwtId = jwtId;
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
issuedAt(epochSeconds) {
|
|
166
|
+
this.#issuedAt = epochSeconds;
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
async build() {
|
|
170
|
+
if (!this.#audience) {
|
|
171
|
+
throw new Error("audience (aud) is required");
|
|
172
|
+
}
|
|
173
|
+
const claims = {
|
|
174
|
+
aud: this.#audience,
|
|
175
|
+
jti: this.#jwtId ?? randomUUID(),
|
|
176
|
+
iat: this.#issuedAt !== null ? this.#issuedAt : nowSeconds(),
|
|
177
|
+
};
|
|
178
|
+
if (this.#clientId) {
|
|
179
|
+
claims.iss = this.#clientId;
|
|
180
|
+
}
|
|
181
|
+
if (this.#challenge) {
|
|
182
|
+
claims.challenge = this.#challenge;
|
|
183
|
+
}
|
|
184
|
+
return signCompact(claims, this.#instanceKey, POP_TYP, false);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Builds a DPoP proof JWT (RFC 9449) for attestation combined mode
|
|
189
|
+
* (PoP method `dpop_combined`): the embedded `jwk` header MUST be the attestation's `cnf` key.
|
|
190
|
+
*/
|
|
191
|
+
export class DpopProofBuilder {
|
|
192
|
+
#instanceKey;
|
|
193
|
+
#htm = "POST";
|
|
194
|
+
#htu = null;
|
|
195
|
+
#nonce = null;
|
|
196
|
+
#ath = null;
|
|
197
|
+
#jwtId = null;
|
|
198
|
+
#issuedAt = null;
|
|
199
|
+
constructor(instanceKey) {
|
|
200
|
+
this.#instanceKey = instanceKey;
|
|
201
|
+
}
|
|
202
|
+
/** The request's HTTP method (`htm`, upper-cased when built); defaults to `POST`. */
|
|
203
|
+
method(htm) {
|
|
204
|
+
if (htm) {
|
|
205
|
+
this.#htm = htm;
|
|
206
|
+
}
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* The target URL (`htu`) — required. Normalised when built: query and fragment are dropped, and
|
|
211
|
+
* scheme and host lower-cased (RFC 9449 section 4.2).
|
|
212
|
+
*/
|
|
213
|
+
uri(htu) {
|
|
214
|
+
this.#htu = htu;
|
|
215
|
+
return this;
|
|
216
|
+
}
|
|
217
|
+
/** The server challenge, carried in DPoP `nonce`. */
|
|
218
|
+
nonce(nonce) {
|
|
219
|
+
this.#nonce = nonce;
|
|
220
|
+
return this;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The access token this proof accompanies at a resource: sets `ath` = base64url(SHA-256(token)).
|
|
224
|
+
* Leave unset for a proof sent to the token endpoint.
|
|
225
|
+
*/
|
|
226
|
+
accessToken(accessToken) {
|
|
227
|
+
this.#ath = accessToken ? accessTokenHash(accessToken) : null;
|
|
228
|
+
return this;
|
|
229
|
+
}
|
|
230
|
+
jwtId(jwtId) {
|
|
231
|
+
this.#jwtId = jwtId;
|
|
232
|
+
return this;
|
|
233
|
+
}
|
|
234
|
+
issuedAt(epochSeconds) {
|
|
235
|
+
this.#issuedAt = epochSeconds;
|
|
236
|
+
return this;
|
|
237
|
+
}
|
|
238
|
+
async build() {
|
|
239
|
+
if (!this.#htu) {
|
|
240
|
+
throw new Error("uri (htu) is required");
|
|
241
|
+
}
|
|
242
|
+
const claims = {
|
|
243
|
+
htm: this.#htm.toUpperCase(),
|
|
244
|
+
htu: normalizeHtu(this.#htu),
|
|
245
|
+
jti: this.#jwtId ?? randomUUID(),
|
|
246
|
+
iat: this.#issuedAt !== null ? this.#issuedAt : nowSeconds(),
|
|
247
|
+
};
|
|
248
|
+
if (this.#nonce) {
|
|
249
|
+
claims.nonce = this.#nonce;
|
|
250
|
+
}
|
|
251
|
+
if (this.#ath) {
|
|
252
|
+
claims.ath = this.#ath;
|
|
253
|
+
}
|
|
254
|
+
return signCompact(claims, this.#instanceKey, DPOP_TYP, true);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** RFC 9449 `ath`: base64url(SHA-256(ASCII access token)). */
|
|
258
|
+
export function accessTokenHash(accessToken) {
|
|
259
|
+
return createHash("sha256").update(accessToken, "ascii").digest("base64url");
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The DPoP `htu` for a URL: scheme, host, port and path, with no query and no fragment. Scheme and host
|
|
263
|
+
* are lower-cased and a default port dropped; a URL with no path keeps none (no slash is invented).
|
|
264
|
+
*/
|
|
265
|
+
export function normalizeHtu(url) {
|
|
266
|
+
const bare = url.split(/[?#]/, 1)[0];
|
|
267
|
+
let parsed;
|
|
268
|
+
try {
|
|
269
|
+
parsed = new URL(bare);
|
|
270
|
+
}
|
|
271
|
+
catch {
|
|
272
|
+
return bare;
|
|
273
|
+
}
|
|
274
|
+
const hasPath = /^[a-z][a-z0-9+.-]*:\/\/[^/]*\//i.test(bare);
|
|
275
|
+
return `${parsed.protocol}//${parsed.host}${hasPath ? parsed.pathname : ""}`;
|
|
276
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SigningKeyPair } from "./signing-key-pair.js";
|
|
2
|
+
export declare const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
3
|
+
export declare const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
4
|
+
export declare const DPOP_HEADER = "DPoP";
|
|
5
|
+
/**
|
|
6
|
+
* The Attester-issued attestation JWT plus the instance key it is bound to. Produces the
|
|
7
|
+
* token-request headers — PoP-JWT mode or DPoP combined mode — minting a fresh proof each call.
|
|
8
|
+
*/
|
|
9
|
+
export declare class ClientAttestationCredential {
|
|
10
|
+
#private;
|
|
11
|
+
constructor(attestationJwt: string, instanceKey: SigningKeyPair);
|
|
12
|
+
/**
|
|
13
|
+
* Headers for dedicated PoP-JWT mode (PoP method `attestation_pop_jwt`):
|
|
14
|
+
* `OAuth-Client-Attestation` + `OAuth-Client-Attestation-PoP`.
|
|
15
|
+
*/
|
|
16
|
+
popHeaders(clientId: string | null, audience: string, challenge?: string | null): Promise<Record<string, string>>;
|
|
17
|
+
/**
|
|
18
|
+
* Headers for DPoP combined mode (PoP method `dpop_combined`):
|
|
19
|
+
* `OAuth-Client-Attestation` + `DPoP` (and no PoP header).
|
|
20
|
+
*/
|
|
21
|
+
dpopHeaders(method: string, uri: string, challenge?: string | null): Promise<Record<string, string>>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Assembles the request headers a client sends at the token endpoint.
|
|
3
|
+
*/
|
|
4
|
+
import { DpopProofBuilder, PopBuilder } from "./builders.js";
|
|
5
|
+
import { requireText } from "./signing-key-pair.js";
|
|
6
|
+
export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
7
|
+
export const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
8
|
+
export const DPOP_HEADER = "DPoP";
|
|
9
|
+
/**
|
|
10
|
+
* The Attester-issued attestation JWT plus the instance key it is bound to. Produces the
|
|
11
|
+
* token-request headers — PoP-JWT mode or DPoP combined mode — minting a fresh proof each call.
|
|
12
|
+
*/
|
|
13
|
+
export class ClientAttestationCredential {
|
|
14
|
+
#attestationJwt;
|
|
15
|
+
#instanceKey;
|
|
16
|
+
constructor(attestationJwt, instanceKey) {
|
|
17
|
+
this.#attestationJwt = requireText(attestationJwt, "attestation_jwt");
|
|
18
|
+
this.#instanceKey = instanceKey;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Headers for dedicated PoP-JWT mode (PoP method `attestation_pop_jwt`):
|
|
22
|
+
* `OAuth-Client-Attestation` + `OAuth-Client-Attestation-PoP`.
|
|
23
|
+
*/
|
|
24
|
+
async popHeaders(clientId, audience, challenge) {
|
|
25
|
+
const pop = await new PopBuilder(this.#instanceKey)
|
|
26
|
+
.clientId(clientId)
|
|
27
|
+
.audience(audience)
|
|
28
|
+
.challenge(challenge ?? null)
|
|
29
|
+
.build();
|
|
30
|
+
return { [ATTESTATION_HEADER]: this.#attestationJwt, [POP_HEADER]: pop };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Headers for DPoP combined mode (PoP method `dpop_combined`):
|
|
34
|
+
* `OAuth-Client-Attestation` + `DPoP` (and no PoP header).
|
|
35
|
+
*/
|
|
36
|
+
async dpopHeaders(method, uri, challenge) {
|
|
37
|
+
const dpop = await new DpopProofBuilder(this.#instanceKey)
|
|
38
|
+
.method(method)
|
|
39
|
+
.uri(uri)
|
|
40
|
+
.nonce(challenge ?? null)
|
|
41
|
+
.build();
|
|
42
|
+
return { [ATTESTATION_HEADER]: this.#attestationJwt, [DPOP_HEADER]: dpop };
|
|
43
|
+
}
|
|
44
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK's error types. {@link ClientAttestationError} is the base for every failure of the
|
|
3
|
+
* one-call flow; {@link TransportError} is the subclass for anything that went wrong on the wire.
|
|
4
|
+
*/
|
|
5
|
+
/** A step in the flow failed. `status` and `body` are set for HTTP failures. */
|
|
6
|
+
export declare class ClientAttestationError extends Error {
|
|
7
|
+
readonly status?: number;
|
|
8
|
+
readonly body?: string;
|
|
9
|
+
constructor(message: string, opts?: {
|
|
10
|
+
status?: number;
|
|
11
|
+
body?: string;
|
|
12
|
+
cause?: unknown;
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Why an outbound HTTP call failed. */
|
|
16
|
+
export type TransportFailure = "network" | "timeout" | "redirect" | "too_large" | "status" | "json";
|
|
17
|
+
/**
|
|
18
|
+
* An outbound HTTP call failed: unreachable, timed out, redirected, oversized, non-2xx, or malformed
|
|
19
|
+
* JSON (see {@link kind}). `status` is set when a response was received; `body` holds at most the first
|
|
20
|
+
* 512 characters of it.
|
|
21
|
+
*/
|
|
22
|
+
export declare class TransportError extends ClientAttestationError {
|
|
23
|
+
readonly kind: TransportFailure;
|
|
24
|
+
constructor(message: string, kind: TransportFailure, opts?: {
|
|
25
|
+
status?: number;
|
|
26
|
+
body?: string;
|
|
27
|
+
cause?: unknown;
|
|
28
|
+
});
|
|
29
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK's error types. {@link ClientAttestationError} is the base for every failure of the
|
|
3
|
+
* one-call flow; {@link TransportError} is the subclass for anything that went wrong on the wire.
|
|
4
|
+
*/
|
|
5
|
+
/** A step in the flow failed. `status` and `body` are set for HTTP failures. */
|
|
6
|
+
export class ClientAttestationError extends Error {
|
|
7
|
+
status;
|
|
8
|
+
body;
|
|
9
|
+
constructor(message, opts) {
|
|
10
|
+
super(message, opts?.cause === undefined ? undefined : { cause: opts.cause });
|
|
11
|
+
this.name = "ClientAttestationError";
|
|
12
|
+
this.status = opts?.status;
|
|
13
|
+
this.body = opts?.body;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* An outbound HTTP call failed: unreachable, timed out, redirected, oversized, non-2xx, or malformed
|
|
18
|
+
* JSON (see {@link kind}). `status` is set when a response was received; `body` holds at most the first
|
|
19
|
+
* 512 characters of it.
|
|
20
|
+
*/
|
|
21
|
+
export class TransportError extends ClientAttestationError {
|
|
22
|
+
kind;
|
|
23
|
+
constructor(message, kind, opts) {
|
|
24
|
+
super(message, opts);
|
|
25
|
+
this.name = "TransportError";
|
|
26
|
+
this.kind = kind;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { type SvidSource } from "./svid-source.js";
|
|
2
|
+
/** Environment variable holding the evidence token itself (static). */
|
|
3
|
+
export declare const ENV_SVID = "CLIENT_ATTESTATION_SVID";
|
|
4
|
+
/** Environment variable holding a path to a projected token file. */
|
|
5
|
+
export declare const ENV_TOKEN_FILE = "CLIENT_ATTESTATION_TOKEN_FILE";
|
|
6
|
+
/** SPIRE Workload API socket (standard SPIFFE var). */
|
|
7
|
+
export declare const ENV_SPIFFE_SOCKET = "SPIFFE_ENDPOINT_SOCKET";
|
|
8
|
+
/** Default projected-token path used by the Kubernetes/GKE serviceAccountToken volume convention. */
|
|
9
|
+
export declare const DEFAULT_TOKEN_FILE = "/var/run/secrets/tokens/attester-token";
|
|
10
|
+
/** Default SPIRE agent socket path. */
|
|
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
|
+
/** No platform identity could be detected and none was supplied. */
|
|
17
|
+
export declare class EvidenceNotFound extends Error {
|
|
18
|
+
constructor(message: string);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Reads the evidence token from a file on every fetch.
|
|
22
|
+
*
|
|
23
|
+
* Kubernetes and GKE mount a short-lived, auto-rotated service-account token into the pod as a file
|
|
24
|
+
* (a projected `serviceAccountToken` volume). Reading it fresh each time picks up the rotation. The
|
|
25
|
+
* file's audience must already be the attester's evidence audience — set it when you declare the volume.
|
|
26
|
+
*/
|
|
27
|
+
export declare class FileTokenSvidSource implements SvidSource {
|
|
28
|
+
private readonly path;
|
|
29
|
+
constructor(path: string);
|
|
30
|
+
fetch(_audience: string): Promise<string>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Fetches a Google-signed ID token for the runtime service account from the GCP metadata server.
|
|
34
|
+
*
|
|
35
|
+
* Works on any Google runtime that exposes the metadata server (GCE, GKE, Cloud Run, Vertex AI Agent
|
|
36
|
+
* Engine). The token's `aud` is the attester's evidence audience; `sub` is the service account. No
|
|
37
|
+
* google-auth dependency — a plain metadata call.
|
|
38
|
+
*/
|
|
39
|
+
export declare class GoogleMetadataSvidSource implements SvidSource {
|
|
40
|
+
private readonly timeoutMs;
|
|
41
|
+
private readonly identityUrl;
|
|
42
|
+
/**
|
|
43
|
+
* @param timeoutMs per-request timeout.
|
|
44
|
+
* @param identityUrl the identity endpoint; internal, for tests - defaults to the real metadata server.
|
|
45
|
+
*/
|
|
46
|
+
constructor(timeoutMs?: number, identityUrl?: string);
|
|
47
|
+
fetch(audience: string): Promise<string>;
|
|
48
|
+
}
|
|
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
|
+
/**
|
|
64
|
+
* Return the first evidence source that applies to this runtime, or throw with guidance.
|
|
65
|
+
*
|
|
66
|
+
* Order: explicit env token, projected token file, SPIRE socket, GCP metadata server. A source named
|
|
67
|
+
* by an environment variable ({@link ENV_TOKEN_FILE}, {@link ENV_SPIFFE_SOCKET}) whose path does not
|
|
68
|
+
* exist throws {@link EvidenceNotFound} naming the variable; it never falls through to the next source.
|
|
69
|
+
*/
|
|
70
|
+
export declare function autoDetectEvidence(opts?: AutoDetectOptions): Promise<SvidSource>;
|
package/dist/evidence.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
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(pfHost)` and have it work without naming the source. Override by
|
|
14
|
+
* passing an explicit {@link SvidSource} to {@link ClientAttestation}.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { readFile } from "node:fs/promises";
|
|
18
|
+
import { TransportError } from "./errors.js";
|
|
19
|
+
import { request, snippet } from "./http.js";
|
|
20
|
+
import { SpireWorkloadApiSvidSource, StaticSvidSource } from "./svid-source.js";
|
|
21
|
+
/** Environment variable holding the evidence token itself (static). */
|
|
22
|
+
export const ENV_SVID = "CLIENT_ATTESTATION_SVID";
|
|
23
|
+
/** Environment variable holding a path to a projected token file. */
|
|
24
|
+
export const ENV_TOKEN_FILE = "CLIENT_ATTESTATION_TOKEN_FILE";
|
|
25
|
+
/** SPIRE Workload API socket (standard SPIFFE var). */
|
|
26
|
+
export const ENV_SPIFFE_SOCKET = "SPIFFE_ENDPOINT_SOCKET";
|
|
27
|
+
/** Default projected-token path used by the Kubernetes/GKE serviceAccountToken volume convention. */
|
|
28
|
+
export const DEFAULT_TOKEN_FILE = "/var/run/secrets/tokens/attester-token";
|
|
29
|
+
/** Default SPIRE agent socket path. */
|
|
30
|
+
export const DEFAULT_SPIFFE_SOCKET = "/tmp/spire-agent/public/api.sock";
|
|
31
|
+
/** GCP metadata server origin. */
|
|
32
|
+
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). */
|
|
34
|
+
export const GCP_METADATA_IDENTITY = GCP_METADATA_BASE + "/computeMetadata/v1/instance/service-accounts/default/identity";
|
|
35
|
+
/** No platform identity could be detected and none was supplied. */
|
|
36
|
+
export class EvidenceNotFound extends Error {
|
|
37
|
+
constructor(message) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = "EvidenceNotFound";
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Reads the evidence token from a file on every fetch.
|
|
44
|
+
*
|
|
45
|
+
* Kubernetes and GKE mount a short-lived, auto-rotated service-account token into the pod as a file
|
|
46
|
+
* (a projected `serviceAccountToken` volume). Reading it fresh each time picks up the rotation. The
|
|
47
|
+
* file's audience must already be the attester's evidence audience — set it when you declare the volume.
|
|
48
|
+
*/
|
|
49
|
+
export class FileTokenSvidSource {
|
|
50
|
+
path;
|
|
51
|
+
constructor(path) {
|
|
52
|
+
this.path = path;
|
|
53
|
+
}
|
|
54
|
+
async fetch(_audience) {
|
|
55
|
+
const contents = await readFile(this.path, "utf8");
|
|
56
|
+
return contents.trim();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Fetches a Google-signed ID token for the runtime service account from the GCP metadata server.
|
|
61
|
+
*
|
|
62
|
+
* Works on any Google runtime that exposes the metadata server (GCE, GKE, Cloud Run, Vertex AI Agent
|
|
63
|
+
* Engine). The token's `aud` is the attester's evidence audience; `sub` is the service account. No
|
|
64
|
+
* google-auth dependency — a plain metadata call.
|
|
65
|
+
*/
|
|
66
|
+
export class GoogleMetadataSvidSource {
|
|
67
|
+
timeoutMs;
|
|
68
|
+
identityUrl;
|
|
69
|
+
/**
|
|
70
|
+
* @param timeoutMs per-request timeout.
|
|
71
|
+
* @param identityUrl the identity endpoint; internal, for tests - defaults to the real metadata server.
|
|
72
|
+
*/
|
|
73
|
+
constructor(timeoutMs = 5000, identityUrl = GCP_METADATA_IDENTITY) {
|
|
74
|
+
this.timeoutMs = timeoutMs;
|
|
75
|
+
this.identityUrl = identityUrl;
|
|
76
|
+
}
|
|
77
|
+
async fetch(audience) {
|
|
78
|
+
const url = `${this.identityUrl}?audience=${encodeURIComponent(audience)}&format=full`;
|
|
79
|
+
const res = await request(url, {
|
|
80
|
+
method: "GET",
|
|
81
|
+
headers: { "Metadata-Flavor": "Google" },
|
|
82
|
+
timeoutMs: this.timeoutMs,
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
throw new TransportError(`GCP metadata identity endpoint -> ${res.status}`, "status", {
|
|
86
|
+
status: res.status,
|
|
87
|
+
body: snippet(res.body),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return res.body.trim();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function gcpMetadataAvailable(base, timeoutMs = 300) {
|
|
94
|
+
try {
|
|
95
|
+
const res = await request(base + "/computeMetadata/v1/", {
|
|
96
|
+
method: "GET",
|
|
97
|
+
headers: { "Metadata-Flavor": "Google" },
|
|
98
|
+
timeoutMs,
|
|
99
|
+
});
|
|
100
|
+
return res.ok;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
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
|
+
export async function autoDetectEvidence(opts = {}) {
|
|
114
|
+
const defaultTokenFile = opts.defaultTokenFile ?? DEFAULT_TOKEN_FILE;
|
|
115
|
+
const defaultSocket = opts.defaultSpiffeSocket ?? DEFAULT_SPIFFE_SOCKET;
|
|
116
|
+
const metadataBase = (opts.gcpMetadataBase ?? GCP_METADATA_BASE).replace(/\/+$/, "");
|
|
117
|
+
const staticToken = process.env[ENV_SVID];
|
|
118
|
+
if (staticToken) {
|
|
119
|
+
return new StaticSvidSource(staticToken.trim());
|
|
120
|
+
}
|
|
121
|
+
// An explicitly configured source that is missing is a misconfiguration: fail closed rather than
|
|
122
|
+
// fall through to a different identity the operator did not choose.
|
|
123
|
+
const tokenFile = process.env[ENV_TOKEN_FILE];
|
|
124
|
+
if (tokenFile) {
|
|
125
|
+
if (!existsSync(tokenFile)) {
|
|
126
|
+
throw new EvidenceNotFound(`${ENV_TOKEN_FILE} is set to ${tokenFile}, but no such file exists`);
|
|
127
|
+
}
|
|
128
|
+
return new FileTokenSvidSource(tokenFile);
|
|
129
|
+
}
|
|
130
|
+
if (existsSync(defaultTokenFile)) {
|
|
131
|
+
return new FileTokenSvidSource(defaultTokenFile);
|
|
132
|
+
}
|
|
133
|
+
const socket = process.env[ENV_SPIFFE_SOCKET];
|
|
134
|
+
if (socket) {
|
|
135
|
+
const socketPath = socket.replace(/^unix:\/\//, "");
|
|
136
|
+
if (!existsSync(socketPath)) {
|
|
137
|
+
throw new EvidenceNotFound(`${ENV_SPIFFE_SOCKET} is set to ${socket}, but no such socket exists`);
|
|
138
|
+
}
|
|
139
|
+
return new SpireWorkloadApiSvidSource(socketPath);
|
|
140
|
+
}
|
|
141
|
+
if (existsSync(defaultSocket)) {
|
|
142
|
+
return new SpireWorkloadApiSvidSource(defaultSocket);
|
|
143
|
+
}
|
|
144
|
+
if (await gcpMetadataAvailable(metadataBase)) {
|
|
145
|
+
return new GoogleMetadataSvidSource(5000, metadataBase + "/computeMetadata/v1/instance/service-accounts/default/identity");
|
|
146
|
+
}
|
|
147
|
+
throw new EvidenceNotFound("Could not auto-detect the workload's platform identity. Set one of " +
|
|
148
|
+
`${ENV_SVID} (the token), ${ENV_TOKEN_FILE} (a token file), or ${ENV_SPIFFE_SOCKET} (SPIRE socket), ` +
|
|
149
|
+
"or pass evidence=<SvidSource> to ClientAttestation explicitly.");
|
|
150
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
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;
|