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