@idpartners/client-attestation-sdk 1.0.0 → 1.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/README.md +142 -22
- package/dist/attester.d.ts +14 -6
- package/dist/attester.js +36 -12
- package/dist/builders.d.ts +11 -3
- package/dist/builders.js +157 -16
- package/dist/errors.d.ts +6 -2
- package/dist/errors.js +6 -2
- package/dist/evidence.d.ts +3 -1
- package/dist/evidence.js +46 -8
- package/dist/http.js +69 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/issuance.d.ts +19 -5
- package/dist/issuance.js +38 -15
- package/dist/logging.js +41 -7
- package/dist/runtime.js +9 -0
- package/dist/signer.d.ts +8 -1
- package/dist/signer.js +32 -5
- package/dist/signing-key-pair.d.ts +6 -0
- package/dist/signing-key-pair.js +91 -5
- package/dist/svid-source.d.ts +13 -2
- package/dist/svid-source.js +204 -17
- package/dist/token-source.d.ts +28 -4
- package/dist/token-source.js +234 -47
- package/dist/validator/config.d.ts +11 -1
- package/dist/validator/config.js +77 -8
- package/dist/validator/index.d.ts +1 -1
- package/dist/validator/index.js +1 -1
- package/dist/validator/jwks.js +65 -20
- package/dist/validator/resource.d.ts +9 -1
- package/dist/validator/resource.js +57 -3
- package/dist/validator/strict.js +6 -1
- package/dist/validator/validator.js +42 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,29 +4,30 @@ Client-side builder SDK for **OAuth Attestation-Based Client Authentication**
|
|
|
4
4
|
(`draft-ietf-oauth-attestation-based-client-auth`). Mints the three artifacts a client
|
|
5
5
|
presents at the token endpoint:
|
|
6
6
|
|
|
7
|
-
- **Client Attestation JWT** (`typ=oauth-client-attestation+jwt`)
|
|
7
|
+
- **Client Attestation JWT** (`typ=oauth-client-attestation+jwt`) - issued by a Client Attester,
|
|
8
8
|
names the client (`sub` = `client_id`) and binds its instance key via `cnf.jwk`.
|
|
9
|
-
- **Client Attestation PoP JWT** (`typ=oauth-client-attestation-pop+jwt`)
|
|
9
|
+
- **Client Attestation PoP JWT** (`typ=oauth-client-attestation-pop+jwt`) - proves possession of
|
|
10
10
|
the instance key (PoP method `attestation_pop_jwt`).
|
|
11
|
-
- **DPoP proof JWT** (`typ=dpop+jwt`)
|
|
11
|
+
- **DPoP proof JWT** (`typ=dpop+jwt`) - combined mode (PoP method `dpop_combined`); its embedded
|
|
12
12
|
`jwk` header is the instance key bound in the attestation's `cnf`.
|
|
13
13
|
|
|
14
|
-
This is one port of a polyglot SDK; it emits
|
|
14
|
+
This is one port of a polyglot SDK; it emits wire-compatible artifacts with the Java, Python and Go
|
|
15
15
|
ports and is judged by a shared Java verifier. Built on [panva `jose`](https://github.com/panva/jose).
|
|
16
16
|
`kid` values are RFC 7638 JWK thumbprints (SHA-256, base64url); `iat`/`exp` are integer epoch seconds.
|
|
17
17
|
|
|
18
|
-
## Install
|
|
18
|
+
## Install
|
|
19
19
|
|
|
20
20
|
Requires **Node 20+**, or an edge runtime with WebCrypto and `fetch` (Cloudflare Workers, Vercel Edge,
|
|
21
21
|
Deno Deploy) with no Node compatibility layer needed. Crypto, encoding and HTTP use web-standard APIs, and
|
|
22
22
|
nothing imports a Node built-in at load time, so the builders, the one-call `ClientAttestation` flow, the
|
|
23
23
|
issuance client and the token validator all work on the edge.
|
|
24
24
|
|
|
25
|
-
CI tests this in a real Workers runtime: `npm run test:edge` bundles the
|
|
26
|
-
and runs
|
|
25
|
+
CI tests this in a real Workers runtime: `npm run test:edge` bundles the sources as an edge bundler would
|
|
26
|
+
and runs them in [`workerd`](https://github.com/cloudflare/workerd), Cloudflare's open-source Workers runtime
|
|
27
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,
|
|
29
|
-
|
|
28
|
+
attestation, a PoP and a DPoP proof, runs the token validator against the shared validation vectors, and
|
|
29
|
+
checks `autoDetectEvidence` and `HttpSvidSource` there. The one-call flow and the issuance client are not
|
|
30
|
+
run in workerd. Vercel Edge and Deno Deploy are not tested directly.
|
|
30
31
|
|
|
31
32
|
Evidence gathering is the part that needs Node. Each of these loads the built-in it needs on first use; the
|
|
32
33
|
two sources throw a `ClientAttestationError` naming themselves from `fetch` on a runtime without it:
|
|
@@ -42,23 +43,61 @@ two sources throw a `ClientAttestationError` naming themselves from `fetch` on a
|
|
|
42
43
|
npm install @idpartners/client-attestation-sdk
|
|
43
44
|
```
|
|
44
45
|
|
|
46
|
+
The package is ESM only (`"type": "module"`, no `require` export), so load it with `import`.
|
|
47
|
+
|
|
45
48
|
To build from a clone:
|
|
46
49
|
|
|
47
50
|
```bash
|
|
48
|
-
|
|
51
|
+
cd typescript # from the repository root
|
|
52
|
+
npm ci
|
|
49
53
|
npm run build # tsc -> dist/
|
|
50
54
|
```
|
|
51
55
|
|
|
52
|
-
|
|
56
|
+
The package exports ESM. Import from `@idpartners/client-attestation-sdk` or
|
|
57
|
+
`@idpartners/client-attestation-sdk/validator`.
|
|
58
|
+
|
|
59
|
+
## Get a token and call an API
|
|
60
|
+
|
|
61
|
+
The attester must trust the workload identity and map it to an authorised client.
|
|
62
|
+
Set `CLIENT_ATTESTATION_TOKEN_FILE` to an identity token file with the attester's evidence
|
|
63
|
+
audience. `ATTESTER_URL`, `EVIDENCE_AUDIENCE` and `API_URL` are application variables.
|
|
64
|
+
`EVIDENCE_AUDIENCE` is the audience your identity token carries, and the attester's discovery document must
|
|
65
|
+
name it too.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
export ATTESTER_URL=https://attester.example.com
|
|
69
|
+
export EVIDENCE_AUDIENCE=https://attester.example.com/evidence
|
|
70
|
+
export API_URL=https://api.example.com/data
|
|
71
|
+
export CLIENT_ATTESTATION_TOKEN_FILE=/absolute/path/to/identity-token.jwt
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Save as `call-api.mjs` in the application that installed the package:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import { ClientAttestation } from "@idpartners/client-attestation-sdk";
|
|
78
|
+
|
|
79
|
+
const ca = new ClientAttestation(process.env.ATTESTER_URL, { evidenceAudience: process.env.EVIDENCE_AUDIENCE });
|
|
80
|
+
const response = await ca.fetch(process.env.API_URL);
|
|
81
|
+
if (!response.ok) throw new Error(`API returned HTTP ${response.status}`);
|
|
82
|
+
console.log(response.status);
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Run `node call-api.mjs`. Retain `ca` for later calls so it reuses the token cache.
|
|
86
|
+
`ca.fetch` attaches the access token and a DPoP proof when required. DPoP binds the token to
|
|
87
|
+
a client key and requires a signed proof for each request.
|
|
88
|
+
See [Configuration](../docs/configuration.md) for the options and evidence sources.
|
|
89
|
+
|
|
90
|
+
## Build a credential offline
|
|
53
91
|
|
|
54
92
|
jose's crypto is async, so key factories and `build()` return promises.
|
|
93
|
+
This complete example generates temporary keys and needs no server.
|
|
55
94
|
|
|
56
95
|
```ts
|
|
57
96
|
import {
|
|
58
97
|
ClientAttestationBuilder,
|
|
59
98
|
ClientAttestationCredential,
|
|
60
99
|
SigningKeyPair,
|
|
61
|
-
} from "client-attestation-sdk";
|
|
100
|
+
} from "@idpartners/client-attestation-sdk";
|
|
62
101
|
|
|
63
102
|
// Attester side: issue the attestation.
|
|
64
103
|
const attester = await SigningKeyPair.generate("ES256"); // or SigningKeyPair.fromJwk(jwk, "ES256")
|
|
@@ -67,9 +106,6 @@ const attestation = await new ClientAttestationBuilder(attester, "https://attest
|
|
|
67
106
|
.clientId("https://rp.example.com")
|
|
68
107
|
.confirmationKey(instance)
|
|
69
108
|
.expiresIn(300)
|
|
70
|
-
// optional: .authorizationDetails(...) / .workload(...) / .operator(sub, issuer?) —
|
|
71
|
-
// operator names who's accountable for running the client (RFC 8693 may_act-shaped; an
|
|
72
|
-
// assertion, not proof of authorisation), distinct from clientId and from the principal.
|
|
73
109
|
.build();
|
|
74
110
|
|
|
75
111
|
// Client side: produce token-request headers.
|
|
@@ -80,8 +116,13 @@ const popHeaders = await cred.popHeaders("https://rp.example.com", "https://as.e
|
|
|
80
116
|
|
|
81
117
|
const dpopHeaders = await cred.dpopHeaders("POST", "https://as.example.com/as/token.oauth2");
|
|
82
118
|
// { "OAuth-Client-Attestation": "...", "DPoP": "..." }
|
|
119
|
+
console.log(Object.keys(popHeaders), Object.keys(dpopHeaders));
|
|
83
120
|
```
|
|
84
121
|
|
|
122
|
+
Save as `build-credential.mjs` and run `node build-credential.mjs` after installing the package.
|
|
123
|
+
The code also runs as TypeScript. A production authorisation server must trust the attester's
|
|
124
|
+
signing key before it accepts a credential.
|
|
125
|
+
|
|
85
126
|
The one-call `ClientAttestation` orchestration class (see the [root README](../README.md#get-a-token-in-one-call))
|
|
86
127
|
wraps this: `{ popMethod: "dpop_combined" }` in its options switches it to DPoP mode, and a fetched
|
|
87
128
|
challenge is carried through automatically as the PoP JWT's `challenge` claim or the DPoP proof's `nonce`
|
|
@@ -92,7 +133,12 @@ parameter. `DRAFT_REVISION` names the draft revision implemented (`"10"`).
|
|
|
92
133
|
### Presenting the token at a resource
|
|
93
134
|
|
|
94
135
|
```ts
|
|
95
|
-
|
|
136
|
+
import { ClientAttestation } from "@idpartners/client-attestation-sdk";
|
|
137
|
+
|
|
138
|
+
const ca = new ClientAttestation("pf.example.com", {
|
|
139
|
+
evidenceAudience: "https://attester.example.com", // the audience your attester expects the identity token for
|
|
140
|
+
popMethod: "dpop_combined",
|
|
141
|
+
});
|
|
96
142
|
await ca.fetch("https://api.example.com/things"); // headers + one nonce retry
|
|
97
143
|
const headers = await ca.requestHeaders("GET", "https://api.example.com/things");
|
|
98
144
|
```
|
|
@@ -104,23 +150,31 @@ token and returns `Authorization: DPoP <t>` plus a fresh `DPoP` proof (`htm`, `h
|
|
|
104
150
|
fragment, `ath`, and the last `DPoP-Nonce` that origin sent). `authorizationHeader()` still works, but for
|
|
105
151
|
a DPoP-bound token it returns only the `DPoP` scheme - a resource will also want the proof.
|
|
106
152
|
|
|
107
|
-
`ca.fetch` resends once when a resource answers with a `use_dpop_nonce` challenge (401
|
|
153
|
+
For a DPoP-bound token, `ca.fetch` resends once when a resource answers with a `use_dpop_nonce` challenge (401
|
|
108
154
|
`WWW-Authenticate: DPoP error="use_dpop_nonce"`, or a 400 JSON error) and a `DPoP-Nonce`. String,
|
|
109
155
|
`URLSearchParams`, `ArrayBuffer` and `Request` bodies are resent; a one-shot `ReadableStream` body is not,
|
|
110
|
-
and the challenge response is returned instead.
|
|
111
|
-
`
|
|
156
|
+
and the challenge response is returned instead. `ca.fetch` uses the global `fetch` with
|
|
157
|
+
`redirect: "manual"`, so a redirect comes back to you and credentials are never resent to its target. In
|
|
158
|
+
`dpop_combined` mode the token request itself retries once when the AS answers 400 `use_dpop_nonce` with
|
|
159
|
+
a `DPoP-Nonce`.
|
|
112
160
|
|
|
113
161
|
### Failing closed
|
|
114
162
|
|
|
115
163
|
- If the attester advertises a `challenge_endpoint` and the challenge cannot be fetched, `token()` throws
|
|
116
164
|
`ClientAttestationError`. Pass `requireChallenge: false` to mint without one instead.
|
|
117
165
|
- `CLIENT_ATTESTATION_TOKEN_FILE` or `SPIFFE_ENDPOINT_SOCKET` pointing at a missing path throws
|
|
118
|
-
`EvidenceNotFound` naming the variable - it never falls through to another identity source.
|
|
166
|
+
`EvidenceNotFound` naming the variable - it never falls through to another identity source. Auto-detect
|
|
167
|
+
checks `CLIENT_ATTESTATION_SVID` first, then the token file, then the SPIRE socket, so a variable is
|
|
168
|
+
only checked if nothing before it matched. `EvidenceNotFound` extends `Error`, not
|
|
169
|
+
`ClientAttestationError`.
|
|
119
170
|
- `challengeAtToken: false` keeps the attester challenge in the mint's instance proof but leaves it out
|
|
120
171
|
of the token-endpoint PoP `challenge` claim / DPoP `nonce` - for attesters that consume a challenge at
|
|
121
172
|
mint and reject it when the token request presents it again.
|
|
122
173
|
- The attester URL and every endpoint the attester advertises must be `https`, except loopback
|
|
123
174
|
(`localhost`, `127.0.0.1`, `::1`). Pass `allowInsecure: true` for, say, an in-cluster plain-http PF.
|
|
175
|
+
`AttestationIssuanceClient.create` (its endpoint and every call its profile makes) and
|
|
176
|
+
`OpenBaoTransitSigner.create` (the vault address) follow the same rule and take the same `allowInsecure`
|
|
177
|
+
option.
|
|
124
178
|
|
|
125
179
|
### Other attesters
|
|
126
180
|
|
|
@@ -130,14 +184,80 @@ directly and skip discovery: `new ClientAttestation({ metadata: { evidenceAudien
|
|
|
130
184
|
tokenEndpoint } })`. `AttestationIssuanceClient.create` takes `{ profile }` too. See
|
|
131
185
|
[`docs/attester-profiles.md`](../docs/attester-profiles.md).
|
|
132
186
|
|
|
187
|
+
## Resource server: validate an access token
|
|
188
|
+
|
|
189
|
+
The validator is the second entry point, `@idpartners/client-attestation-sdk/validator`. The order of
|
|
190
|
+
checks and the error codes are shared with the other ports and pinned by
|
|
191
|
+
[`validation/SEMANTICS.md`](../validation/SEMANTICS.md).
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
import { AccessTokenValidator, ProtectedResource } from "@idpartners/client-attestation-sdk/validator";
|
|
195
|
+
|
|
196
|
+
const validator = new AccessTokenValidator({
|
|
197
|
+
issuer: "https://issuer.example.com",
|
|
198
|
+
audiences: ["https://api.example.com"],
|
|
199
|
+
jwksUri: "https://issuer.example.com/jwks",
|
|
200
|
+
requiredScopes: ["read"],
|
|
201
|
+
});
|
|
202
|
+
const result = await validator.validate(accessToken);
|
|
203
|
+
if (!result.valid) console.log(result.error); // e.g. "expired", "insufficient_scope"
|
|
204
|
+
// validate() ignores cnf: refuse result.claims.cnf unless you verify the sender proof (see below)
|
|
205
|
+
|
|
206
|
+
const resource = new ProtectedResource("https://api.example.com", ["https://issuer.example.com"], validator);
|
|
207
|
+
const decision = await resource.authenticate(request.headers.get("authorization"));
|
|
208
|
+
// decision.status is 401, or 403 for missing scope; send decision.wwwAuthenticate back
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
By default a token's header `typ`, when present, must be `at+jwt`; pass `acceptedTokenTypes: null` for an
|
|
212
|
+
issuer that stamps something else. `jwksUri` and the introspection `endpoint` must use https unless their
|
|
213
|
+
host is loopback (`localhost`, `127.0.0.1`, `::1`); `allowInsecure: true` lifts that.
|
|
214
|
+
|
|
215
|
+
`validate` checks the token only. It doesn't look at `cnf`, so a DPoP- or mTLS-bound token is `valid`
|
|
216
|
+
without its sender proof: refuse one unless you verify that proof. `ProtectedResource.authenticate` is the
|
|
217
|
+
request guard, and it refuses a token with a `cnf` claim with 401, because it never sees the sender proof.
|
|
218
|
+
A runnable server is in
|
|
219
|
+
[`examples/protected-resource-server.ts`](examples/protected-resource-server.ts).
|
|
220
|
+
|
|
133
221
|
The validator's `ProtectedResource` strips control characters from every quoted `WWW-Authenticate` value,
|
|
134
222
|
and validation failures never echo the token's own `alg`, `kid`, `iss` or `aud` back in
|
|
135
223
|
`error_description`.
|
|
136
224
|
|
|
137
|
-
##
|
|
225
|
+
## Token validator
|
|
226
|
+
|
|
227
|
+
This fragment runs inside a resource-server request handler that supplies `accessToken`:
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
import { AccessTokenValidator } from "@idpartners/client-attestation-sdk/validator";
|
|
231
|
+
|
|
232
|
+
const validator = new AccessTokenValidator({
|
|
233
|
+
issuer: "https://issuer.example.com",
|
|
234
|
+
audiences: ["https://api.example.com"],
|
|
235
|
+
jwksUri: "https://issuer.example.com/jwks",
|
|
236
|
+
requiredScopes: ["read"],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// In the handler:
|
|
240
|
+
// const result = await validator.validate(accessToken);
|
|
241
|
+
// if (!result.valid) reject(result.error);
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`validate` checks the JWT signature and claims. `validateActive` uses RFC 7662 introspection
|
|
245
|
+
with configured resource-server credentials. It does not first run `validate`.
|
|
246
|
+
See [resource configuration](../docs/configuration.md#resource-server-configuration) and the
|
|
247
|
+
[resource-server example](../docs/examples.md#protected-resource-examples).
|
|
248
|
+
`ProtectedResource.authenticate` handles Bearer requests. It does not verify DPoP proofs.
|
|
249
|
+
|
|
250
|
+
## Build, test and package
|
|
138
251
|
|
|
139
252
|
```bash
|
|
140
|
-
npm test # vitest
|
|
253
|
+
npm test # vitest - self-verifies each artifact with jose
|
|
254
|
+
npm run coverage
|
|
141
255
|
npm run test:edge # the same SDK in workerd, Cloudflare's Workers runtime, with no Node compatibility
|
|
256
|
+
npm run build
|
|
257
|
+
npm pack # builds and creates a local .tgz archive; does not publish
|
|
142
258
|
npm run emit # writes ../vectors/out/typescript.json for the cross-language Java verifier
|
|
143
259
|
```
|
|
260
|
+
|
|
261
|
+
Run these from `typescript/`. See [Build and test](../docs/building.md#typescript) for outputs
|
|
262
|
+
and cross-language checks, [Examples](../docs/examples.md) for server prerequisites, and
|
|
263
|
+
[Deployment](../docs/deployment.md) for deploying an application with the SDK.
|
package/dist/attester.d.ts
CHANGED
|
@@ -44,14 +44,14 @@ export interface AttesterProfile {
|
|
|
44
44
|
/** Exchange the evidence and a proof of the instance key for an attestation. */
|
|
45
45
|
mint(transport: AttesterTransport, metadata: AttesterMetadata, request: MintRequest): Promise<MintResult>;
|
|
46
46
|
}
|
|
47
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Everything a profile needs to mint one attestation. The evidence and the challenge are getters over
|
|
49
|
+
* private fields, so `JSON.stringify` and `console.log` of a request show neither.
|
|
50
|
+
*/
|
|
48
51
|
export declare class MintRequest {
|
|
52
|
+
#private;
|
|
49
53
|
/** The fresh key the attestation will be bound to (its `cnf`). */
|
|
50
54
|
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
55
|
/** Set by {@link AttestationIssuanceClient}; unset in the evidence-first one-call flow. */
|
|
56
56
|
readonly clientId?: string;
|
|
57
57
|
/** Optional RFC 9396 access request. */
|
|
@@ -66,7 +66,15 @@ export declare class MintRequest {
|
|
|
66
66
|
authorizationDetails?: Array<Record<string, unknown>>;
|
|
67
67
|
operator?: Record<string, unknown>;
|
|
68
68
|
});
|
|
69
|
-
/**
|
|
69
|
+
/** The workload's platform identity token (a JWT-SVID or equivalent). */
|
|
70
|
+
get evidence(): string;
|
|
71
|
+
/** The attester-issued challenge, if any. */
|
|
72
|
+
get challenge(): string | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Sign `claims` as a compact JWS with the instance key, under header `typ` (no `jwk`). `typ` may not be
|
|
75
|
+
* one the SDK's own credentials use (`ATTESTATION_TYP`, `POP_TYP`, `DPOP_TYP`): a profile could otherwise
|
|
76
|
+
* have the instance key sign a Client Attestation PoP or DPoP proof with claims of its choosing.
|
|
77
|
+
*/
|
|
70
78
|
sign(claims: Record<string, unknown>, typ: string): Promise<string>;
|
|
71
79
|
/** A proof of possession of the instance key: `{aud, jti, iat[, challenge]}` signed with it. */
|
|
72
80
|
instanceProof(typ: string, audience: string | undefined): Promise<string>;
|
package/dist/attester.js
CHANGED
|
@@ -9,16 +9,18 @@
|
|
|
9
9
|
* {@link ClientAttestation} and {@link AttestationIssuanceClient} use {@link PingFederateProfile} unless
|
|
10
10
|
* told otherwise, which is exactly the behaviour of earlier releases. See docs/attester-profiles.md.
|
|
11
11
|
*/
|
|
12
|
+
import { ATTESTATION_TYP, DPOP_TYP, POP_TYP } from "./builders.js";
|
|
12
13
|
import { ClientAttestationError } from "./errors.js";
|
|
13
14
|
import { signCompact } from "./signing-key-pair.js";
|
|
14
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Everything a profile needs to mint one attestation. The evidence and the challenge are getters over
|
|
17
|
+
* private fields, so `JSON.stringify` and `console.log` of a request show neither.
|
|
18
|
+
*/
|
|
15
19
|
export class MintRequest {
|
|
16
20
|
/** The fresh key the attestation will be bound to (its `cnf`). */
|
|
17
21
|
instanceKey;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/** The attester-issued challenge, if any. */
|
|
21
|
-
challenge;
|
|
22
|
+
#evidence;
|
|
23
|
+
#challenge;
|
|
22
24
|
/** Set by {@link AttestationIssuanceClient}; unset in the evidence-first one-call flow. */
|
|
23
25
|
clientId;
|
|
24
26
|
/** Optional RFC 9396 access request. */
|
|
@@ -27,14 +29,29 @@ export class MintRequest {
|
|
|
27
29
|
operator;
|
|
28
30
|
constructor(init) {
|
|
29
31
|
this.instanceKey = init.instanceKey;
|
|
30
|
-
this
|
|
31
|
-
this
|
|
32
|
+
this.#evidence = init.evidence;
|
|
33
|
+
this.#challenge = init.challenge;
|
|
32
34
|
this.clientId = init.clientId;
|
|
33
35
|
this.authorizationDetails = init.authorizationDetails;
|
|
34
36
|
this.operator = init.operator;
|
|
35
37
|
}
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
+
/** The workload's platform identity token (a JWT-SVID or equivalent). */
|
|
39
|
+
get evidence() {
|
|
40
|
+
return this.#evidence;
|
|
41
|
+
}
|
|
42
|
+
/** The attester-issued challenge, if any. */
|
|
43
|
+
get challenge() {
|
|
44
|
+
return this.#challenge;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Sign `claims` as a compact JWS with the instance key, under header `typ` (no `jwk`). `typ` may not be
|
|
48
|
+
* one the SDK's own credentials use (`ATTESTATION_TYP`, `POP_TYP`, `DPOP_TYP`): a profile could otherwise
|
|
49
|
+
* have the instance key sign a Client Attestation PoP or DPoP proof with claims of its choosing.
|
|
50
|
+
*/
|
|
51
|
+
async sign(claims, typ) {
|
|
52
|
+
if (reservedTyp(typ)) {
|
|
53
|
+
throw new Error(`typ ${JSON.stringify(typ)} is reserved for the SDK's own credentials; a profile cannot sign it with the instance key`);
|
|
54
|
+
}
|
|
38
55
|
return signCompact(claims, this.instanceKey, typ, false);
|
|
39
56
|
}
|
|
40
57
|
/** A proof of possession of the instance key: `{aud, jti, iat[, challenge]}` signed with it. */
|
|
@@ -49,6 +66,14 @@ export class MintRequest {
|
|
|
49
66
|
return this.sign(claims, typ);
|
|
50
67
|
}
|
|
51
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether `typ` names one of the SDK's own credential types, compared as RFC 7515 section 4.1.9 says a
|
|
71
|
+
* verifier may: case-insensitively, with or without the `application/` prefix.
|
|
72
|
+
*/
|
|
73
|
+
function reservedTyp(typ) {
|
|
74
|
+
const t = String(typ).trim().toLowerCase().replace(/^application\//, "");
|
|
75
|
+
return t === ATTESTATION_TYP || t === POP_TYP || t === DPOP_TYP;
|
|
76
|
+
}
|
|
52
77
|
/**
|
|
53
78
|
* The PingFederate profile's discovery document path.
|
|
54
79
|
*
|
|
@@ -108,9 +133,8 @@ export class PingFederateProfile {
|
|
|
108
133
|
const response = await transport.requestJson("mint", "POST", metadata.attestationEndpoint, body);
|
|
109
134
|
const attestation = response.attestation;
|
|
110
135
|
if (typeof attestation !== "string" || !attestation) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
});
|
|
136
|
+
// No body: a 2xx mint response may hold the attestation under another name.
|
|
137
|
+
throw new ClientAttestationError("attester response contained no attestation");
|
|
114
138
|
}
|
|
115
139
|
const expiresIn = response.expires_in;
|
|
116
140
|
return { attestation, expiresIn: typeof expiresIn === "number" ? expiresIn : undefined };
|
package/dist/builders.d.ts
CHANGED
|
@@ -16,7 +16,12 @@ export declare class ClientAttestationBuilder {
|
|
|
16
16
|
constructor(attesterKey: SigningKeyPair | JwsSigner, issuer: string);
|
|
17
17
|
/** The client being attested — becomes the attestation `sub` (= `client_id`). */
|
|
18
18
|
clientId(clientId: string): this;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Binds the client instance key as `cnf.jwk`: an EC (`crv` P-256, P-384 or P-521, `x`, `y`) or RSA
|
|
21
|
+
* (`n`, `e`) public key. Throws for one carrying private or secret members (`d`, `p`, `q`, `dp`, `dq`,
|
|
22
|
+
* `qi`, `oth`, `k`) or another `kty`. Only those members and an optional `kid` are copied into the
|
|
23
|
+
* attestation.
|
|
24
|
+
*/
|
|
20
25
|
confirmationJwk(publicInstanceJwk: JWK): this;
|
|
21
26
|
/** Convenience: binds the public half of the given instance key as `cnf.jwk`. */
|
|
22
27
|
confirmationKey(instanceKey: SigningKeyPair): this;
|
|
@@ -75,8 +80,11 @@ export declare class DpopProofBuilder {
|
|
|
75
80
|
/** The request's HTTP method (`htm`, upper-cased when built); defaults to `POST`. */
|
|
76
81
|
method(htm: string | null | undefined): this;
|
|
77
82
|
/**
|
|
78
|
-
* The target URL (`htu`) — required. Normalised when built
|
|
79
|
-
*
|
|
83
|
+
* The target URL (`htu`) — required. Normalised when built (RFC 9449 section 4.2) by the rule in
|
|
84
|
+
* vectors/README.md: userinfo, query, fragment and a default port are dropped, and scheme and host
|
|
85
|
+
* lower-cased. `build` refuses a URL that is not an absolute http(s) URL with a valid ASCII host, port
|
|
86
|
+
* and path, or that has a backslash, ASCII whitespace or a control character before its query or
|
|
87
|
+
* fragment.
|
|
80
88
|
*/
|
|
81
89
|
uri(htu: string): this;
|
|
82
90
|
/** The server challenge, carried in DPoP `nonce`. */
|
package/dist/builders.js
CHANGED
|
@@ -35,9 +35,14 @@ export class ClientAttestationBuilder {
|
|
|
35
35
|
this.#clientId = clientId;
|
|
36
36
|
return this;
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Binds the client instance key as `cnf.jwk`: an EC (`crv` P-256, P-384 or P-521, `x`, `y`) or RSA
|
|
40
|
+
* (`n`, `e`) public key. Throws for one carrying private or secret members (`d`, `p`, `q`, `dp`, `dq`,
|
|
41
|
+
* `qi`, `oth`, `k`) or another `kty`. Only those members and an optional `kid` are copied into the
|
|
42
|
+
* attestation.
|
|
43
|
+
*/
|
|
39
44
|
confirmationJwk(publicInstanceJwk) {
|
|
40
|
-
this.#cnfJwk = publicInstanceJwk;
|
|
45
|
+
this.#cnfJwk = confirmationJwkMembers(publicInstanceJwk);
|
|
41
46
|
return this;
|
|
42
47
|
}
|
|
43
48
|
/** Convenience: binds the public half of the given instance key as `cnf.jwk`. */
|
|
@@ -122,14 +127,61 @@ export class ClientAttestationBuilder {
|
|
|
122
127
|
return signExternal(claims, this.#attesterKey, ATTESTATION_TYP);
|
|
123
128
|
}
|
|
124
129
|
#resolveExpiry(iat) {
|
|
130
|
+
let exp;
|
|
125
131
|
if (this.#expiresAt !== null) {
|
|
126
|
-
|
|
132
|
+
exp = this.#expiresAt;
|
|
133
|
+
}
|
|
134
|
+
else if (this.#ttl !== null) {
|
|
135
|
+
exp = iat + this.#ttl;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
throw new Error("expiry is required: call expiresAt(...) or expiresIn(...)");
|
|
139
|
+
}
|
|
140
|
+
if (!(exp > iat)) {
|
|
141
|
+
throw new Error(`attestation exp (${exp}) must be after iat (${iat})`);
|
|
142
|
+
}
|
|
143
|
+
return exp;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** JWK members that carry private or secret key material (RFC 7518 section 6). */
|
|
147
|
+
const PRIVATE_JWK_MEMBERS = ["d", "p", "q", "dp", "dq", "qi", "oth", "k"];
|
|
148
|
+
/** The public members a `cnf.jwk` of each key type must carry. */
|
|
149
|
+
const CONFIRMATION_JWK_MEMBERS = { EC: ["crv", "x", "y"], RSA: ["n", "e"] };
|
|
150
|
+
/**
|
|
151
|
+
* The public key to bind as `cnf.jwk`: `kty`, the key type's public members and an optional `kid`, in the
|
|
152
|
+
* order the input has them. Refuses private or secret members, another `kty` or curve, or a missing member,
|
|
153
|
+
* so a private key is never signed into an attestation.
|
|
154
|
+
*/
|
|
155
|
+
function confirmationJwkMembers(jwk) {
|
|
156
|
+
if (jwk === null || typeof jwk !== "object" || Array.isArray(jwk)) {
|
|
157
|
+
throw new Error("cnf.jwk must be a JWK object");
|
|
158
|
+
}
|
|
159
|
+
const members = jwk;
|
|
160
|
+
for (const member of PRIVATE_JWK_MEMBERS) {
|
|
161
|
+
if (member in members) {
|
|
162
|
+
throw new Error(`cnf.jwk must be a public key: it carries the private member '${member}'`);
|
|
127
163
|
}
|
|
128
|
-
|
|
129
|
-
|
|
164
|
+
}
|
|
165
|
+
const kty = members.kty;
|
|
166
|
+
const required = typeof kty === "string" && Object.hasOwn(CONFIRMATION_JWK_MEMBERS, kty)
|
|
167
|
+
? CONFIRMATION_JWK_MEMBERS[kty]
|
|
168
|
+
: undefined;
|
|
169
|
+
if (!required) {
|
|
170
|
+
throw new Error(`cnf.jwk must be an EC or RSA public key, not kty ${JSON.stringify(kty)}`);
|
|
171
|
+
}
|
|
172
|
+
for (const member of required) {
|
|
173
|
+
if (typeof members[member] !== "string" || members[member] === "") {
|
|
174
|
+
throw new Error(`cnf.jwk (kty ${kty}) is missing '${member}'`);
|
|
130
175
|
}
|
|
131
|
-
throw new Error("expiry is required: call expiresAt(...) or expiresIn(...)");
|
|
132
176
|
}
|
|
177
|
+
if (kty === "EC" && !["P-256", "P-384", "P-521"].includes(members.crv)) {
|
|
178
|
+
throw new Error(`cnf.jwk curve must be P-256, P-384 or P-521, not ${JSON.stringify(members.crv)}`);
|
|
179
|
+
}
|
|
180
|
+
if ("kid" in members && (typeof members.kid !== "string" || members.kid === "")) {
|
|
181
|
+
throw new Error("cnf.jwk kid must be a non-empty string");
|
|
182
|
+
}
|
|
183
|
+
const keep = new Set(["kty", "kid", ...required]);
|
|
184
|
+
return Object.fromEntries(Object.entries(members).filter(([k]) => keep.has(k)));
|
|
133
185
|
}
|
|
134
186
|
/**
|
|
135
187
|
* Builds a Client Attestation PoP JWT proving possession of the instance key. Client side of
|
|
@@ -208,8 +260,11 @@ export class DpopProofBuilder {
|
|
|
208
260
|
return this;
|
|
209
261
|
}
|
|
210
262
|
/**
|
|
211
|
-
* The target URL (`htu`) — required. Normalised when built
|
|
212
|
-
*
|
|
263
|
+
* The target URL (`htu`) — required. Normalised when built (RFC 9449 section 4.2) by the rule in
|
|
264
|
+
* vectors/README.md: userinfo, query, fragment and a default port are dropped, and scheme and host
|
|
265
|
+
* lower-cased. `build` refuses a URL that is not an absolute http(s) URL with a valid ASCII host, port
|
|
266
|
+
* and path, or that has a backslash, ASCII whitespace or a control character before its query or
|
|
267
|
+
* fragment.
|
|
213
268
|
*/
|
|
214
269
|
uri(htu) {
|
|
215
270
|
this.#htu = htu;
|
|
@@ -265,20 +320,106 @@ export async function accessTokenHash(accessToken) {
|
|
|
265
320
|
return base64url.encode(new Uint8Array(digest));
|
|
266
321
|
}
|
|
267
322
|
/**
|
|
268
|
-
*
|
|
269
|
-
*
|
|
323
|
+
* Throws unless `url` is free of backslashes, ASCII whitespace and control characters before its query or
|
|
324
|
+
* fragment (the part that becomes `htu`). URL parsers disagree on where such a URL goes - WHATWG reads
|
|
325
|
+
* `https://evil.example\@as.example/t` as a request to evil.example, `urllib.parse` as one to
|
|
326
|
+
* as.example - so a proof's `htu` could name a different server from the one the request reaches.
|
|
270
327
|
*
|
|
271
328
|
* @internal
|
|
272
329
|
*/
|
|
273
|
-
export function
|
|
330
|
+
export function checkHtu(url) {
|
|
331
|
+
const head = url.split(/[?#]/, 1)[0];
|
|
332
|
+
if (/[\x00-\x20\x7f\\]/.test(head)) {
|
|
333
|
+
throw new Error("uri (htu) must not contain a backslash, whitespace or control character");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
/** The schemes an `htu` may have, with their default ports. */
|
|
337
|
+
const HTU_DEFAULT_PORTS = { http: "80", https: "443" };
|
|
338
|
+
const HTU_NOT_ABSOLUTE = "uri (htu) must be an absolute http or https URL with a host";
|
|
339
|
+
const HTU_HOST = "uri (htu) has an invalid host";
|
|
340
|
+
const HTU_PORT = "uri (htu) has an invalid port";
|
|
341
|
+
const HTU_PATH = "uri (htu) has an invalid path";
|
|
342
|
+
/**
|
|
343
|
+
* `[origin, path]` of a request URL's `htu`, by the rule in vectors/README.md that every port applies
|
|
344
|
+
* alike: an absolute http(s) URL with a host; scheme and host lower-cased (ASCII only, an IPv6 literal as
|
|
345
|
+
* written); no userinfo; the port as a plain number and dropped when it is the scheme's default; the path
|
|
346
|
+
* as written with non-ASCII characters percent-encoded (UTF-8); no query or fragment. It
|
|
347
|
+
* is read from the string itself, not through WHATWG `URL`, which would also resolve dot-segments,
|
|
348
|
+
* compress IPv6 and convert IDN hosts. Anything else throws.
|
|
349
|
+
*
|
|
350
|
+
* @internal
|
|
351
|
+
*/
|
|
352
|
+
export function htuParts(url) {
|
|
353
|
+
checkHtu(url);
|
|
274
354
|
const bare = url.split(/[?#]/, 1)[0];
|
|
275
|
-
|
|
355
|
+
const sep = bare.indexOf("://");
|
|
356
|
+
const scheme = sep < 0 ? "" : bare.slice(0, sep).toLowerCase();
|
|
357
|
+
if (!Object.hasOwn(HTU_DEFAULT_PORTS, scheme)) {
|
|
358
|
+
throw new Error(HTU_NOT_ABSOLUTE);
|
|
359
|
+
}
|
|
360
|
+
const rest = bare.slice(sep + 3);
|
|
361
|
+
const slash = rest.indexOf("/");
|
|
362
|
+
const authority = slash < 0 ? rest : rest.slice(0, slash);
|
|
363
|
+
const path = slash < 0 ? "" : rest.slice(slash);
|
|
364
|
+
const hostport = authority.slice(authority.lastIndexOf("@") + 1);
|
|
365
|
+
let host;
|
|
366
|
+
let port = "";
|
|
367
|
+
if (hostport.startsWith("[")) {
|
|
368
|
+
const close = hostport.indexOf("]");
|
|
369
|
+
const literal = close < 0 ? "" : hostport.slice(1, close);
|
|
370
|
+
const after = close < 0 ? "" : hostport.slice(close + 1);
|
|
371
|
+
if (!literal.includes(":") || !/^[0-9A-Fa-f:.]*$/.test(literal) || (after !== "" && after[0] !== ":")) {
|
|
372
|
+
throw new Error(HTU_HOST);
|
|
373
|
+
}
|
|
374
|
+
host = `[${literal.toLowerCase()}]`;
|
|
375
|
+
port = after.slice(1);
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
const colon = hostport.indexOf(":");
|
|
379
|
+
host = colon < 0 ? hostport : hostport.slice(0, colon);
|
|
380
|
+
port = colon < 0 ? "" : hostport.slice(colon + 1);
|
|
381
|
+
if (host === "") {
|
|
382
|
+
throw new Error(HTU_NOT_ABSOLUTE);
|
|
383
|
+
}
|
|
384
|
+
if (!/^[A-Za-z0-9\-._~]+$/.test(host)) {
|
|
385
|
+
throw new Error(HTU_HOST);
|
|
386
|
+
}
|
|
387
|
+
host = host.toLowerCase();
|
|
388
|
+
}
|
|
389
|
+
if (port !== "") {
|
|
390
|
+
const digits = port.replace(/^0+/, "");
|
|
391
|
+
if (!/^[0-9]+$/.test(port) || digits.length > 5 || Number(digits) > 65535) {
|
|
392
|
+
throw new Error(HTU_PORT);
|
|
393
|
+
}
|
|
394
|
+
const n = String(Number(digits));
|
|
395
|
+
if (n !== HTU_DEFAULT_PORTS[scheme]) {
|
|
396
|
+
host += `:${n}`;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return [`${scheme}://${host}`, encodeHtuPath(path)];
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* `path` with every non-ASCII character percent-encoded as UTF-8 (upper-case hex) and every ASCII
|
|
403
|
+
* character kept as written, `%XX` escapes, a lone `%` and characters outside RFC 3986 `pchar` included:
|
|
404
|
+
* those are what the HTTP client sends, and a server compares `htu` after RFC 3986 section 6.2.2
|
|
405
|
+
* normalisation, which decodes only unreserved characters. (`checkHtu` has already refused controls,
|
|
406
|
+
* whitespace and backslash.) A lone surrogate is refused.
|
|
407
|
+
*/
|
|
408
|
+
function encodeHtuPath(path) {
|
|
276
409
|
try {
|
|
277
|
-
|
|
410
|
+
return path.replace(/[^\x00-\x7f]+/g, (run) => encodeURIComponent(run)); // throws on a lone surrogate
|
|
278
411
|
}
|
|
279
412
|
catch {
|
|
280
|
-
|
|
413
|
+
throw new Error(HTU_PATH);
|
|
281
414
|
}
|
|
282
|
-
|
|
283
|
-
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* The DPoP `htu` for a URL (RFC 9449 section 4.2): the origin and path {@link htuParts} gives. A URL
|
|
418
|
+
* with no path keeps none (no slash is invented).
|
|
419
|
+
*
|
|
420
|
+
* @internal
|
|
421
|
+
*/
|
|
422
|
+
export function normalizeHtu(url) {
|
|
423
|
+
const [origin, path] = htuParts(url);
|
|
424
|
+
return origin + path;
|
|
284
425
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
* The SDK's error types. {@link ClientAttestationError} is the base for every failure of the
|
|
3
3
|
* one-call flow; {@link TransportError} is the subclass for anything that went wrong on the wire.
|
|
4
4
|
*/
|
|
5
|
-
/**
|
|
5
|
+
/**
|
|
6
|
+
* A step in the flow failed. `status` and `body` are set for HTTP failures: `body` is at most the first 512
|
|
7
|
+
* characters of a non-2xx response, and unset for a 2xx response that could not be used, since that body
|
|
8
|
+
* may hold a token, attestation or SVID.
|
|
9
|
+
*/
|
|
6
10
|
export declare class ClientAttestationError extends Error {
|
|
7
11
|
readonly status?: number;
|
|
8
12
|
readonly body?: string;
|
|
@@ -17,7 +21,7 @@ export type TransportFailure = "network" | "timeout" | "redirect" | "too_large"
|
|
|
17
21
|
/**
|
|
18
22
|
* An outbound HTTP call failed: unreachable, timed out, redirected, oversized, non-2xx, or malformed
|
|
19
23
|
* JSON (see {@link kind}). `status` is set when a response was received; `body` holds at most the first
|
|
20
|
-
* 512 characters of
|
|
24
|
+
* 512 characters of a non-2xx one.
|
|
21
25
|
*/
|
|
22
26
|
export declare class TransportError extends ClientAttestationError {
|
|
23
27
|
readonly kind: TransportFailure;
|