@idpartners/client-attestation-sdk 1.0.1 → 1.4.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 +23 -9
- package/dist/attester.js +40 -14
- package/dist/builders.d.ts +11 -3
- package/dist/builders.js +157 -16
- package/dist/credential.d.ts +2 -0
- package/dist/credential.js +2 -0
- 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 +8 -4
- package/dist/index.js +8 -4
- 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 +14 -2
- package/dist/svid-source.js +200 -16
- package/dist/token-source.d.ts +28 -4
- package/dist/token-source.js +285 -63
- package/dist/validator/config.d.ts +39 -5
- package/dist/validator/config.js +89 -8
- package/dist/validator/index.d.ts +2 -2
- package/dist/validator/index.js +2 -2
- package/dist/validator/jwks.js +101 -22
- package/dist/validator/resource.d.ts +8 -0
- package/dist/validator/resource.js +50 -3
- package/dist/validator/strict.js +6 -1
- package/dist/validator/validator.js +50 -13
- package/dist/www-authenticate.d.ts +7 -0
- package/dist/www-authenticate.js +114 -0
- package/package.json +2 -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
|
@@ -7,8 +7,14 @@ export interface AttesterMetadata {
|
|
|
7
7
|
attestationEndpoint?: string;
|
|
8
8
|
/** The authorization server's token endpoint. A profile fills in its own fallback. */
|
|
9
9
|
tokenEndpoint?: string;
|
|
10
|
-
/** The
|
|
10
|
+
/** The attester's challenge endpoint (CAS section 4.1), if the attester issues challenges for the mint. */
|
|
11
11
|
challengeEndpoint?: string;
|
|
12
|
+
/**
|
|
13
|
+
* The authorization server's challenge endpoint (draft -11 section 6.3), if it issues challenges for
|
|
14
|
+
* the token request: `POST`ed before each token request, and its `attestation_challenge` carried as
|
|
15
|
+
* the PoP JWT's `challenge` claim or the DPoP proof's `nonce`. PingFederate: `/federation/attestation-challenge`.
|
|
16
|
+
*/
|
|
17
|
+
tokenChallengeEndpoint?: string;
|
|
12
18
|
/** The Client Attestation PoP `aud` (the AS issuer); falls back to the token endpoint URL. */
|
|
13
19
|
popAudience?: string;
|
|
14
20
|
/** The raw discovery document, when there was one; returned by `ClientAttestation.discovery()`. */
|
|
@@ -44,14 +50,14 @@ export interface AttesterProfile {
|
|
|
44
50
|
/** Exchange the evidence and a proof of the instance key for an attestation. */
|
|
45
51
|
mint(transport: AttesterTransport, metadata: AttesterMetadata, request: MintRequest): Promise<MintResult>;
|
|
46
52
|
}
|
|
47
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Everything a profile needs to mint one attestation. The evidence and the challenge are getters over
|
|
55
|
+
* private fields, so `JSON.stringify` and `console.log` of a request show neither.
|
|
56
|
+
*/
|
|
48
57
|
export declare class MintRequest {
|
|
58
|
+
#private;
|
|
49
59
|
/** The fresh key the attestation will be bound to (its `cnf`). */
|
|
50
60
|
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
61
|
/** Set by {@link AttestationIssuanceClient}; unset in the evidence-first one-call flow. */
|
|
56
62
|
readonly clientId?: string;
|
|
57
63
|
/** Optional RFC 9396 access request. */
|
|
@@ -66,7 +72,15 @@ export declare class MintRequest {
|
|
|
66
72
|
authorizationDetails?: Array<Record<string, unknown>>;
|
|
67
73
|
operator?: Record<string, unknown>;
|
|
68
74
|
});
|
|
69
|
-
/**
|
|
75
|
+
/** The workload's platform identity token (a JWT-SVID or equivalent). */
|
|
76
|
+
get evidence(): string;
|
|
77
|
+
/** The attester-issued challenge, if any. */
|
|
78
|
+
get challenge(): string | undefined;
|
|
79
|
+
/**
|
|
80
|
+
* Sign `claims` as a compact JWS with the instance key, under header `typ` (no `jwk`). `typ` may not be
|
|
81
|
+
* one the SDK's own credentials use (`ATTESTATION_TYP`, `POP_TYP`, `DPOP_TYP`): a profile could otherwise
|
|
82
|
+
* have the instance key sign a Client Attestation PoP or DPoP proof with claims of its choosing.
|
|
83
|
+
*/
|
|
70
84
|
sign(claims: Record<string, unknown>, typ: string): Promise<string>;
|
|
71
85
|
/** A proof of possession of the instance key: `{aud, jti, iat[, challenge]}` signed with it. */
|
|
72
86
|
instanceProof(typ: string, audience: string | undefined): Promise<string>;
|
|
@@ -77,8 +91,8 @@ export declare const INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt"
|
|
|
77
91
|
* The attester this SDK was built against (PingFederate), and the default.
|
|
78
92
|
*
|
|
79
93
|
* - Discovery: `GET {attesterUrl}/.well-known/client-attester`, a JSON object with `evidence_audience`,
|
|
80
|
-
* `attestation_endpoint`, `challenge_endpoint`, `token_endpoint` and
|
|
81
|
-
* `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
|
|
94
|
+
* `attestation_endpoint`, `challenge_endpoint`, `token_endpoint`, `token_challenge_endpoint` and
|
|
95
|
+
* `pop_audience`. With no `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
|
|
82
96
|
* - Mint: `POST attestation_endpoint` with JSON `{[client_id,] instance_key, svid, proof
|
|
83
97
|
* [, authorization_details][, operator]}`, where `proof` is `{aud: evidence_audience, jti, iat
|
|
84
98
|
* [, challenge]}` signed by the instance key with `typ` `oauth-attestation-instance-proof+jwt`. The
|
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
|
*
|
|
@@ -61,8 +86,8 @@ export const INSTANCE_PROOF_TYP = "oauth-attestation-instance-proof+jwt";
|
|
|
61
86
|
* The attester this SDK was built against (PingFederate), and the default.
|
|
62
87
|
*
|
|
63
88
|
* - Discovery: `GET {attesterUrl}/.well-known/client-attester`, a JSON object with `evidence_audience`,
|
|
64
|
-
* `attestation_endpoint`, `challenge_endpoint`, `token_endpoint` and
|
|
65
|
-
* `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
|
|
89
|
+
* `attestation_endpoint`, `challenge_endpoint`, `token_endpoint`, `token_challenge_endpoint` and
|
|
90
|
+
* `pop_audience`. With no `token_endpoint`, `{attesterUrl}/as/token.oauth2`.
|
|
66
91
|
* - Mint: `POST attestation_endpoint` with JSON `{[client_id,] instance_key, svid, proof
|
|
67
92
|
* [, authorization_details][, operator]}`, where `proof` is `{aud: evidence_audience, jti, iat
|
|
68
93
|
* [, challenge]}` signed by the instance key with `typ` `oauth-attestation-instance-proof+jwt`. The
|
|
@@ -84,6 +109,7 @@ export class PingFederateProfile {
|
|
|
84
109
|
// discovery document still works from the host alone.
|
|
85
110
|
tokenEndpoint: str("token_endpoint") ?? attesterUrl + PingFederateProfile.DEFAULT_TOKEN_PATH,
|
|
86
111
|
challengeEndpoint: str("challenge_endpoint"),
|
|
112
|
+
tokenChallengeEndpoint: str("token_challenge_endpoint"),
|
|
87
113
|
popAudience: str("pop_audience"),
|
|
88
114
|
document: doc,
|
|
89
115
|
};
|
|
@@ -108,9 +134,8 @@ export class PingFederateProfile {
|
|
|
108
134
|
const response = await transport.requestJson("mint", "POST", metadata.attestationEndpoint, body);
|
|
109
135
|
const attestation = response.attestation;
|
|
110
136
|
if (typeof attestation !== "string" || !attestation) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
});
|
|
137
|
+
// No body: a 2xx mint response may hold the attestation under another name.
|
|
138
|
+
throw new ClientAttestationError("attester response contained no attestation");
|
|
114
139
|
}
|
|
115
140
|
const expiresIn = response.expires_in;
|
|
116
141
|
return { attestation, expiresIn: typeof expiresIn === "number" ? expiresIn : undefined };
|
|
@@ -130,6 +155,7 @@ export function metadataDocument(metadata) {
|
|
|
130
155
|
["attestation_endpoint", metadata.attestationEndpoint],
|
|
131
156
|
["challenge_endpoint", metadata.challengeEndpoint],
|
|
132
157
|
["token_endpoint", metadata.tokenEndpoint],
|
|
158
|
+
["token_challenge_endpoint", metadata.tokenChallengeEndpoint],
|
|
133
159
|
["pop_audience", metadata.popAudience],
|
|
134
160
|
];
|
|
135
161
|
for (const [key, value] of fields)
|
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`. */
|