@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 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`) — issued by a Client Attester,
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`) — proves possession of
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`) — combined mode (PoP method `dpop_combined`); its embedded
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 byte-compatible artifacts with the Java and Python
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 / build
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 package as an edge bundler would
26
- and runs it in [`workerd`](https://github.com/cloudflare/workerd), Cloudflare's open-source Workers runtime
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, and runs the token validator against the shared validation vectors.
29
- Vercel Edge and Deno Deploy are not tested directly.
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
- npm install
51
+ cd typescript # from the repository root
52
+ npm ci
49
53
  npm run build # tsc -> dist/
50
54
  ```
51
55
 
52
- ## Usage
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
- const ca = new ClientAttestation("pf.example.com", { popMethod: "dpop_combined" });
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. The token request itself retries once on an AS's
111
- `use_dpop_nonce` demand.
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
- ## Test & interop
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 — self-verifies each artifact with jose
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.
@@ -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
- /** Everything a profile needs to mint one attestation. */
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
- /** Sign `claims` as a compact JWS with the instance key, under header `typ` (no `jwk`). */
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
- /** Everything a profile needs to mint one attestation. */
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
- /** The workload's platform identity token (a JWT-SVID or equivalent). */
19
- evidence;
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.evidence = init.evidence;
31
- this.challenge = init.challenge;
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
- /** Sign `claims` as a compact JWS with the instance key, under header `typ` (no `jwk`). */
37
- sign(claims, typ) {
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
- throw new ClientAttestationError("attester response contained no attestation", {
112
- body: JSON.stringify(response),
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 };
@@ -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
- /** Binds the client instance key as `cnf.jwk`. Must be a public JWK. */
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: query and fragment are dropped, and
79
- * scheme and host lower-cased (RFC 9449 section 4.2).
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
- /** Binds the client instance key as `cnf.jwk`. Must be a public JWK. */
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
- return this.#expiresAt;
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
- if (this.#ttl !== null) {
129
- return iat + this.#ttl;
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: query and fragment are dropped, and
212
- * scheme and host lower-cased (RFC 9449 section 4.2).
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
- * The DPoP `htu` for a URL: scheme, host, port and path, with no query and no fragment. Scheme and host
269
- * are lower-cased and a default port dropped; a URL with no path keeps none (no slash is invented).
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 normalizeHtu(url) {
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
- let parsed;
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
- parsed = new URL(bare);
410
+ return path.replace(/[^\x00-\x7f]+/g, (run) => encodeURIComponent(run)); // throws on a lone surrogate
278
411
  }
279
412
  catch {
280
- return bare;
413
+ throw new Error(HTU_PATH);
281
414
  }
282
- const hasPath = /^[a-z][a-z0-9+.-]*:\/\/[^/]*\//i.test(bare);
283
- return `${parsed.protocol}//${parsed.host}${hasPath ? parsed.pathname : ""}`;
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
- /** A step in the flow failed. `status` and `body` are set for HTTP failures. */
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 it.
24
+ * 512 characters of a non-2xx one.
21
25
  */
22
26
  export declare class TransportError extends ClientAttestationError {
23
27
  readonly kind: TransportFailure;