@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/dist/token-source.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* import { ClientAttestation } from "@idpartners/client-attestation-sdk";
|
|
13
13
|
*
|
|
14
|
-
* const ca = new ClientAttestation("pf.example.com");
|
|
14
|
+
* const ca = new ClientAttestation("pf.example.com", { evidenceAudience: "https://attester.example.com/evidence" });
|
|
15
15
|
* const token = await ca.token(); // cached until it nears expiry
|
|
16
16
|
*
|
|
17
17
|
* // the fetch wrapper attaches the token (and a DPoP proof for a DPoP-bound token):
|
|
@@ -20,33 +20,53 @@
|
|
|
20
20
|
* const headers = await ca.requestHeaders("GET", "https://api.example.com/things");
|
|
21
21
|
*/
|
|
22
22
|
import { base64url } from "jose";
|
|
23
|
-
import { DpopProofBuilder } from "./builders.js";
|
|
24
|
-
import { ClientAttestationCredential, DPOP_HEADER } from "./credential.js";
|
|
23
|
+
import { checkHtu, DpopProofBuilder, htuParts, normalizeHtu } from "./builders.js";
|
|
24
|
+
import { CHALLENGE_HEADER, ClientAttestationCredential, DPOP_HEADER } from "./credential.js";
|
|
25
25
|
import { ClientAttestationError, TransportError } from "./errors.js";
|
|
26
26
|
import { autoDetectEvidence } from "./evidence.js";
|
|
27
27
|
import { MintRequest, PingFederateProfile, metadataDocument, } from "./attester.js";
|
|
28
|
-
import { jsonOk, request } from "./http.js";
|
|
28
|
+
import { checkSecure, jsonOk, request, snippet } from "./http.js";
|
|
29
29
|
import { emit, failureReason, hostOf, NOOP_LOGGER, urlParts } from "./logging.js";
|
|
30
|
-
import { utf8Decode } from "./runtime.js";
|
|
30
|
+
import { monotonicMs, utf8Decode } from "./runtime.js";
|
|
31
31
|
import { SigningKeyPair } from "./signing-key-pair.js";
|
|
32
|
+
import { isDpopNonceChallenge } from "./www-authenticate.js";
|
|
32
33
|
export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
33
34
|
export const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
34
35
|
/** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
|
|
35
36
|
export const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
|
|
37
|
+
const POP_JWT = "attestation_pop_jwt";
|
|
38
|
+
const DPOP_COMBINED = "dpop_combined";
|
|
39
|
+
const POP_METHODS = [POP_JWT, DPOP_COMBINED];
|
|
40
|
+
const PIN_REQUIRED = "evidenceAudience is required: set it to the audience your attester expects the workload's " +
|
|
41
|
+
"identity token for, so the attester's discovery document can't choose it";
|
|
36
42
|
export { ClientAttestationError, TransportError } from "./errors.js";
|
|
37
43
|
const USE_DPOP_NONCE = "use_dpop_nonce";
|
|
38
44
|
const DPOP_NONCE_HEADER = "DPoP-Nonce";
|
|
45
|
+
/** draft -11 section 6.1: the refusal a server that uses challenges answers a PoP-JWT request without one. */
|
|
46
|
+
const USE_ATTESTATION_CHALLENGE = "use_attestation_challenge";
|
|
47
|
+
/** At most this much of a 400 body is read to look for `use_dpop_nonce`; a longer body is not a challenge. */
|
|
48
|
+
const MAX_NONCE_CHALLENGE_BODY = 64 * 1024;
|
|
49
|
+
/** RFC 9449 nonces are short opaque strings: a longer `DPoP-Nonce` is ignored, as if there were none. */
|
|
50
|
+
const MAX_DPOP_NONCE_LENGTH = 1024;
|
|
51
|
+
/**
|
|
52
|
+
* At most this many origins' nonces are remembered; a new origin over the cap replaces the one stored
|
|
53
|
+
* longest ago (it costs that origin one challenge).
|
|
54
|
+
*/
|
|
55
|
+
const MAX_DPOP_NONCE_ORIGINS = 256;
|
|
56
|
+
/** A token is cached for at most this long (seconds), whatever `expires_in` the token endpoint gives. */
|
|
57
|
+
const MAX_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
39
58
|
/** What the https check calls the URL a profile's call goes to, by step. */
|
|
40
59
|
const STEP_LABELS = {
|
|
41
60
|
discover: "attesterUrl",
|
|
42
61
|
mint: "attestation_endpoint",
|
|
43
62
|
challenge: "challenge_endpoint",
|
|
63
|
+
token_challenge: "token_challenge_endpoint",
|
|
44
64
|
};
|
|
45
65
|
/**
|
|
46
66
|
* Gets and caches an access token using client attestation.
|
|
47
67
|
*
|
|
48
68
|
* `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
|
|
49
|
-
* discovery) or a custom `opts.profile`.
|
|
69
|
+
* discovery) or a custom `opts.profile`. A client that discovers its attester needs `opts.evidenceAudience`.
|
|
50
70
|
*
|
|
51
71
|
* @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
|
|
52
72
|
* `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
|
|
@@ -70,8 +90,10 @@ export class ClientAttestation {
|
|
|
70
90
|
/** The token, its type and its bound key: always replaced together, never one without the others. */
|
|
71
91
|
#state = null;
|
|
72
92
|
#metadata;
|
|
93
|
+
#staticMetadata;
|
|
94
|
+
#evidenceAudience;
|
|
73
95
|
#refreshing = null;
|
|
74
|
-
/** The last `DPoP-Nonce` each origin returned. */
|
|
96
|
+
/** The last `DPoP-Nonce` each origin returned, oldest first. */
|
|
75
97
|
#nonces = new Map();
|
|
76
98
|
constructor(attesterUrl, opts = {}) {
|
|
77
99
|
if (typeof attesterUrl === "object" && attesterUrl !== null) {
|
|
@@ -81,14 +103,19 @@ export class ClientAttestation {
|
|
|
81
103
|
const url = attesterUrl ?? opts.attesterUrl;
|
|
82
104
|
this.#profile = opts.profile ?? new PingFederateProfile();
|
|
83
105
|
this.#metadata = opts.metadata ?? null;
|
|
106
|
+
this.#staticMetadata = this.#metadata !== null;
|
|
107
|
+
this.#evidenceAudience = opts.evidenceAudience;
|
|
84
108
|
this.#evidence = opts.evidence;
|
|
85
109
|
this.#authorizationDetails = opts.authorizationDetails;
|
|
86
110
|
this.#operator = opts.operator && Object.keys(opts.operator).length > 0 ? opts.operator : undefined;
|
|
87
111
|
this.#challengeAtToken = opts.challengeAtToken ?? true;
|
|
88
112
|
this.#algorithm = opts.algorithm ?? "ES256";
|
|
89
113
|
this.#skew = opts.expirySkewSeconds ?? DEFAULT_EXPIRY_SKEW_SECONDS;
|
|
114
|
+
if (!Number.isFinite(this.#skew) || this.#skew < 0) {
|
|
115
|
+
throw new RangeError(`expirySkewSeconds must be a finite number, not negative: ${this.#skew}`);
|
|
116
|
+
}
|
|
90
117
|
this.#timeoutMs = opts.timeoutMs ?? 20_000;
|
|
91
|
-
this.#popMethod = opts.popMethod
|
|
118
|
+
this.#popMethod = checkPopMethod(opts.popMethod);
|
|
92
119
|
this.#requireChallenge = opts.requireChallenge ?? true;
|
|
93
120
|
this.#allowInsecure = opts.allowInsecure ?? false;
|
|
94
121
|
this.#logger = opts.logger ?? NOOP_LOGGER;
|
|
@@ -99,6 +126,14 @@ export class ClientAttestation {
|
|
|
99
126
|
this.baseUrl = normalizeBaseUrl(url);
|
|
100
127
|
this.#checkSecure(this.baseUrl, "attesterUrl");
|
|
101
128
|
}
|
|
129
|
+
// A client given an evidence source is set up to mint, and a client that discovers its attester needs the
|
|
130
|
+
// pin to mint: without it the discovery document would choose the audience the workload's identity token
|
|
131
|
+
// is for. Refused here, at startup, not at the first token(); last, so a bad URL or option is reported as
|
|
132
|
+
// itself first. A pin-less client with auto-detected evidence still builds (attesterMetadata() needs no
|
|
133
|
+
// pin); runFlow refuses its token().
|
|
134
|
+
if (this.#evidence != null && !this.#evidenceAudience && !this.#staticMetadata) {
|
|
135
|
+
throw new ClientAttestationError(PIN_REQUIRED);
|
|
136
|
+
}
|
|
102
137
|
}
|
|
103
138
|
// ── public API ──────────────────────────────────────────────────────────────────────────────────
|
|
104
139
|
/** Return a valid access token, minting a new one only when the cached one is near expiry. */
|
|
@@ -121,16 +156,54 @@ export class ClientAttestation {
|
|
|
121
156
|
* - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
|
|
122
157
|
* token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
|
|
123
158
|
* url's origin returned, if any.
|
|
159
|
+
*
|
|
160
|
+
* `url` must be https, like every other URL the client sends a credential to (loopback hosts and
|
|
161
|
+
* `allowInsecure` excepted).
|
|
162
|
+
*
|
|
163
|
+
* Pass the exact URL your HTTP client sends: the proof is only valid for the `htu` it names. A url with
|
|
164
|
+
* a backslash, ASCII whitespace or a control character before its query or fragment is refused with a
|
|
165
|
+
* {@link ClientAttestationError}, because URL parsers disagree on where it goes. For a DPoP-bound token,
|
|
166
|
+
* so is a url that is not an absolute http(s) URL with a valid ASCII host, port and path; with
|
|
167
|
+
* `popMethod: "dpop_combined"` that is checked before the token is fetched. The path is signed as
|
|
168
|
+
* written, so pass `new URL(u).href` (what `fetch` sends) rather than a hand-written string: `fetch` may
|
|
169
|
+
* escape characters (a `{`, say) that the string leaves as they are. {@link fetch} does this for you.
|
|
124
170
|
*/
|
|
125
171
|
async requestHeaders(method, url, opts = {}) {
|
|
172
|
+
return this.#requestHeaders(method, url, opts);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* {@link requestHeaders}, with the proof carrying `nonce` when given (the nonce of the challenge being
|
|
176
|
+
* answered) instead of the origin's remembered one.
|
|
177
|
+
*/
|
|
178
|
+
async #requestHeaders(method, url, opts, nonce) {
|
|
179
|
+
this.#checkSecure(url, "resource URL");
|
|
180
|
+
// A client configured for DPoP checks the whole htu before fetching the token, so a URL with no htu
|
|
181
|
+
// never costs a mint and token request on a cold cache. Otherwise only what parsers disagree on is
|
|
182
|
+
// refused up front: a Bearer token carries no htu.
|
|
183
|
+
try {
|
|
184
|
+
if (this.#popMethod === DPOP_COMBINED)
|
|
185
|
+
normalizeHtu(url);
|
|
186
|
+
else
|
|
187
|
+
checkHtu(url);
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
throw new ClientAttestationError(err.message);
|
|
191
|
+
}
|
|
126
192
|
const state = await this.#current(opts);
|
|
127
193
|
const headers = { Authorization: `${scheme(state)} ${state.accessToken}` };
|
|
128
194
|
if (isDpop(state)) {
|
|
195
|
+
// Also for a DPoP token from a client not configured for DPoP (the AS chose the token type).
|
|
196
|
+
try {
|
|
197
|
+
normalizeHtu(url);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
throw new ClientAttestationError(err.message);
|
|
201
|
+
}
|
|
129
202
|
headers[DPOP_HEADER] = await new DpopProofBuilder(state.key)
|
|
130
203
|
.method(method)
|
|
131
204
|
.uri(url)
|
|
132
205
|
.accessToken(state.accessToken)
|
|
133
|
-
.nonce(this.#nonces.get(originOf(url)) ?? null)
|
|
206
|
+
.nonce(nonce ?? this.#nonces.get(originOf(url)) ?? null)
|
|
134
207
|
.build();
|
|
135
208
|
}
|
|
136
209
|
return headers;
|
|
@@ -144,14 +217,17 @@ export class ClientAttestation {
|
|
|
144
217
|
*/
|
|
145
218
|
async fetch(input, init = {}) {
|
|
146
219
|
const isRequest = typeof Request !== "undefined" && input instanceof Request;
|
|
147
|
-
|
|
220
|
+
// The proof names the URL as fetch parses it (WHATWG), which is where the request goes.
|
|
221
|
+
const url = isRequest ? input.url : parsedHref(String(input));
|
|
148
222
|
const method = (init.method ?? (isRequest ? input.method : "GET")).toUpperCase();
|
|
149
223
|
// A Request's own body is cloned so the retry can send it again.
|
|
150
224
|
const nextInput = () => (isRequest ? input.clone() : input);
|
|
151
225
|
let dpopBound = false;
|
|
152
|
-
const send = async () => {
|
|
226
|
+
const send = async (nonce) => {
|
|
153
227
|
const headers = new Headers(init.headers ?? (isRequest ? input.headers : undefined));
|
|
154
|
-
const auth =
|
|
228
|
+
const auth = nonce === undefined
|
|
229
|
+
? await this.requestHeaders(method, url)
|
|
230
|
+
: await this.#requestHeaders(method, url, {}, nonce);
|
|
155
231
|
dpopBound = DPOP_HEADER in auth;
|
|
156
232
|
for (const [name, value] of Object.entries(auth))
|
|
157
233
|
headers.set(name, value);
|
|
@@ -160,14 +236,24 @@ export class ClientAttestation {
|
|
|
160
236
|
const first = await send();
|
|
161
237
|
if (!dpopBound)
|
|
162
238
|
return first;
|
|
163
|
-
const nonce = first.headers.get(DPOP_NONCE_HEADER);
|
|
239
|
+
const nonce = usableNonce(first.headers.get(DPOP_NONCE_HEADER));
|
|
164
240
|
if (nonce)
|
|
165
|
-
this.#
|
|
166
|
-
if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
|
|
241
|
+
this.#rememberNonce(url, nonce);
|
|
242
|
+
if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first, this.#timeoutMs)))
|
|
167
243
|
return first;
|
|
168
244
|
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(url), status: first.status, at: "resource" });
|
|
169
245
|
await first.body?.cancel().catch(() => undefined);
|
|
170
|
-
|
|
246
|
+
// Answer this challenge with its own nonce, not one a concurrent response stored since.
|
|
247
|
+
return send(nonce);
|
|
248
|
+
}
|
|
249
|
+
/** Store `nonce` as the origin's latest, forgetting the origin stored longest ago over the cap. */
|
|
250
|
+
#rememberNonce(url, nonce) {
|
|
251
|
+
const key = originOf(url);
|
|
252
|
+
this.#nonces.delete(key);
|
|
253
|
+
if (this.#nonces.size >= MAX_DPOP_NONCE_ORIGINS) {
|
|
254
|
+
this.#nonces.delete(this.#nonces.keys().next().value);
|
|
255
|
+
}
|
|
256
|
+
this.#nonces.set(key, nonce);
|
|
171
257
|
}
|
|
172
258
|
/**
|
|
173
259
|
* The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
|
|
@@ -201,9 +287,10 @@ export class ClientAttestation {
|
|
|
201
287
|
/** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
|
|
202
288
|
async #current(opts) {
|
|
203
289
|
const state = this.#state;
|
|
204
|
-
|
|
290
|
+
const now = monotonicMs();
|
|
291
|
+
if (!opts.forceRefresh && state && now < state.expiresAt) {
|
|
205
292
|
emit(this.#logger, "debug", "token.cache_hit", {
|
|
206
|
-
expires_in: Math.max(0, Math.round((state.expiresAt -
|
|
293
|
+
expires_in: Math.max(0, Math.round((state.expiresAt - now) / 1000)),
|
|
207
294
|
});
|
|
208
295
|
return state;
|
|
209
296
|
}
|
|
@@ -219,27 +306,38 @@ export class ClientAttestation {
|
|
|
219
306
|
}
|
|
220
307
|
async #refresh() {
|
|
221
308
|
const { response, key } = await this.#runFlow();
|
|
222
|
-
const
|
|
309
|
+
const lifetime = tokenLifetime(response.expires_in);
|
|
223
310
|
const state = {
|
|
224
311
|
accessToken: response.access_token,
|
|
225
312
|
tokenType: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
|
|
226
313
|
key,
|
|
227
|
-
expiresAt:
|
|
314
|
+
expiresAt: lifetime !== undefined ? monotonicMs() + (lifetime - this.#skew) * 1000 : 0,
|
|
228
315
|
};
|
|
229
316
|
this.#state = state;
|
|
230
317
|
return state;
|
|
231
318
|
}
|
|
232
319
|
async #runFlow() {
|
|
320
|
+
// The constructor refused anything else. A value that still isn't one of the two fails here, before
|
|
321
|
+
// any request, rather than falling back to a PoP JWT.
|
|
322
|
+
if (!POP_METHODS.includes(this.#popMethod)) {
|
|
323
|
+
throw new ClientAttestationError(`unsupported popMethod ${describe(this.#popMethod)}`);
|
|
324
|
+
}
|
|
325
|
+
if (!this.#evidenceAudience && !this.#staticMetadata)
|
|
326
|
+
throw new ClientAttestationError(PIN_REQUIRED);
|
|
233
327
|
const meta = await this.attesterMetadata();
|
|
234
328
|
const audience = meta.evidenceAudience;
|
|
235
329
|
if (!audience) {
|
|
236
330
|
throw new ClientAttestationError("attester discovery document has no evidence_audience");
|
|
237
331
|
}
|
|
332
|
+
if (this.#evidenceAudience && audience !== this.#evidenceAudience) {
|
|
333
|
+
throw new ClientAttestationError(`the attester asks for evidence for ${JSON.stringify(audience)}, but evidenceAudience is ` +
|
|
334
|
+
JSON.stringify(this.#evidenceAudience));
|
|
335
|
+
}
|
|
238
336
|
const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
|
|
239
337
|
const svid = await evidenceSource.fetch(audience);
|
|
240
338
|
emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
|
|
241
339
|
const instanceKey = await SigningKeyPair.generate(this.#algorithm);
|
|
242
|
-
const challenge = await this.#fetchChallenge(meta.challengeEndpoint);
|
|
340
|
+
const challenge = await this.#fetchChallenge(meta.challengeEndpoint, "attester");
|
|
243
341
|
const { attestation } = await this.#profile.mint(this.#transport(), meta, new MintRequest({
|
|
244
342
|
instanceKey,
|
|
245
343
|
evidence: svid,
|
|
@@ -263,53 +361,77 @@ export class ClientAttestation {
|
|
|
263
361
|
// Falling back to the token endpoint URL only works when the dialed URL matches PF's issuer (no
|
|
264
362
|
// proxy in between) — so prefer the advertised value.
|
|
265
363
|
const popAudience = meta.popAudience || tokenEndpoint;
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
364
|
+
// The challenge the token-endpoint proof carries: the authorization server's own, from its challenge
|
|
365
|
+
// endpoint (draft -11 section 6.3), when the metadata names one; else the attester's (see
|
|
366
|
+
// `challengeAtToken`: some attesters consume the challenge the moment the mint's instance proof uses
|
|
367
|
+
// it, and reject a token request that presents the same value a second time; against one of those,
|
|
368
|
+
// only the mint gets the challenge). A challenge the server demands in a refusal (section 6.1
|
|
369
|
+
// `use_attestation_challenge`, or RFC 9449 `use_dpop_nonce` in combined mode) wins over either.
|
|
370
|
+
const tokenChallenge = meta.tokenChallengeEndpoint
|
|
371
|
+
? await this.#fetchChallenge(meta.tokenChallengeEndpoint, "authorization_server")
|
|
372
|
+
: this.#challengeAtToken
|
|
373
|
+
? challenge
|
|
374
|
+
: undefined;
|
|
270
375
|
const credential = new ClientAttestationCredential(attestation, instanceKey);
|
|
271
376
|
const obtained = (response) => {
|
|
272
|
-
const
|
|
377
|
+
const lifetime = tokenLifetime(response.expires_in);
|
|
273
378
|
emit(this.#logger, "info", "token.obtained", {
|
|
274
379
|
host: hostOf(tokenEndpoint),
|
|
275
380
|
client_id: clientId,
|
|
276
381
|
token_type: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
|
|
277
|
-
expires_in:
|
|
382
|
+
expires_in: lifetime !== undefined ? Math.trunc(lifetime) : undefined,
|
|
278
383
|
pop_method: this.#popMethod,
|
|
279
384
|
});
|
|
280
385
|
return { response, key: instanceKey };
|
|
281
386
|
};
|
|
282
|
-
if (this.#popMethod
|
|
283
|
-
|
|
284
|
-
|
|
387
|
+
if (this.#popMethod === POP_JWT) {
|
|
388
|
+
let res = await this.#postToken(tokenEndpoint, await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null));
|
|
389
|
+
// draft -11 section 6.1: a server that uses challenges refuses a request without a usable one as
|
|
390
|
+
// 400 use_attestation_challenge and always carries a fresh challenge in
|
|
391
|
+
// OAuth-Client-Attestation-Challenge. Send once more with that one. A refusal with no header
|
|
392
|
+
// (or one too long to be a challenge), any other error, or a second refusal is the error the
|
|
393
|
+
// caller sees: a server that keeps refusing is not retried in a loop.
|
|
394
|
+
const served = usableNonce(res.headers.get(CHALLENGE_HEADER));
|
|
395
|
+
if (res.status === 400 && served && jsonError(res.body) === USE_ATTESTATION_CHALLENGE) {
|
|
396
|
+
emit(this.#logger, "info", "pop.challenge_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
|
|
397
|
+
res = await this.#postToken(tokenEndpoint, await credential.popHeaders(clientId, popAudience, served));
|
|
398
|
+
}
|
|
399
|
+
return obtained(this.#tokenResponse(tokenEndpoint, res));
|
|
285
400
|
}
|
|
286
|
-
// DPoP combined
|
|
287
|
-
//
|
|
401
|
+
// DPoP combined, the only other value (checked at the top of the flow): the challenge rides as the
|
|
402
|
+
// DPoP nonce, and RFC 9449's nonce mechanism is the only challenge mechanism in this mode (draft -11
|
|
403
|
+
// section 5.2: no `challenge` claim, no OAuth-Client-Attestation-Challenge). If the AS demands its
|
|
404
|
+
// own nonce (400 use_dpop_nonce + DPoP-Nonce), retry once with that — the server's demand wins.
|
|
288
405
|
let res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, tokenChallenge ?? null));
|
|
289
|
-
const serverNonce = res.headers.get(DPOP_NONCE_HEADER);
|
|
406
|
+
const serverNonce = usableNonce(res.headers.get(DPOP_NONCE_HEADER));
|
|
290
407
|
if (serverNonce)
|
|
291
|
-
this.#
|
|
408
|
+
this.#rememberNonce(tokenEndpoint, serverNonce);
|
|
292
409
|
if (res.status === 400 && serverNonce && jsonError(res.body) === USE_DPOP_NONCE) {
|
|
293
410
|
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
|
|
294
411
|
res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
|
|
295
412
|
}
|
|
296
413
|
return obtained(this.#tokenResponse(tokenEndpoint, res));
|
|
297
414
|
}
|
|
298
|
-
|
|
415
|
+
/**
|
|
416
|
+
* `POST` a challenge endpoint - the attester's (CAS section 4.1, for the mint) or the authorization
|
|
417
|
+
* server's (draft -11 section 6.3, for the token request); both answer `{"attestation_challenge"}`.
|
|
418
|
+
*/
|
|
419
|
+
async #fetchChallenge(endpoint, at) {
|
|
299
420
|
if (!endpoint)
|
|
300
421
|
return undefined;
|
|
301
|
-
|
|
302
|
-
|
|
422
|
+
const step = at === "attester" ? "challenge" : "token_challenge";
|
|
423
|
+
this.#checkSecure(endpoint, STEP_LABELS[step]);
|
|
424
|
+
// The server advertised a challenge, so going on without one weakens replay protection: fail
|
|
303
425
|
// closed unless the caller opted out with requireChallenge: false.
|
|
304
426
|
let body;
|
|
305
427
|
try {
|
|
306
|
-
body = await this.#getJson(
|
|
428
|
+
body = await this.#getJson(step, "POST", endpoint);
|
|
307
429
|
}
|
|
308
430
|
catch (err) {
|
|
309
431
|
if (!(err instanceof ClientAttestationError))
|
|
310
432
|
throw err;
|
|
311
433
|
if (!this.#requireChallenge) {
|
|
312
|
-
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
|
|
434
|
+
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint), at });
|
|
313
435
|
return undefined;
|
|
314
436
|
}
|
|
315
437
|
throw new ClientAttestationError(`could not fetch the attestation challenge from ${endpoint}: ${err.message}`, {
|
|
@@ -320,11 +442,11 @@ export class ClientAttestation {
|
|
|
320
442
|
}
|
|
321
443
|
const challenge = body.attestation_challenge;
|
|
322
444
|
if (typeof challenge === "string" && challenge) {
|
|
323
|
-
emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint) });
|
|
445
|
+
emit(this.#logger, "debug", "challenge.fetched", { host: hostOf(endpoint), at });
|
|
324
446
|
return challenge;
|
|
325
447
|
}
|
|
326
448
|
if (!this.#requireChallenge) {
|
|
327
|
-
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint) });
|
|
449
|
+
emit(this.#logger, "warn", "challenge.skipped", { host: hostOf(endpoint), at });
|
|
328
450
|
return undefined;
|
|
329
451
|
}
|
|
330
452
|
throw new ClientAttestationError(`challenge endpoint ${endpoint} returned no attestation_challenge`);
|
|
@@ -362,18 +484,7 @@ export class ClientAttestation {
|
|
|
362
484
|
}
|
|
363
485
|
/** Refuse a non-https URL unless it is loopback or the caller passed `allowInsecure`. */
|
|
364
486
|
#checkSecure(url, what) {
|
|
365
|
-
|
|
366
|
-
return;
|
|
367
|
-
let parsed;
|
|
368
|
-
try {
|
|
369
|
-
parsed = new URL(url);
|
|
370
|
-
}
|
|
371
|
-
catch {
|
|
372
|
-
throw new ClientAttestationError(`${what} is not a valid URL: ${url}`);
|
|
373
|
-
}
|
|
374
|
-
if (parsed.protocol === "https:" || LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
|
|
375
|
-
return;
|
|
376
|
-
throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
|
|
487
|
+
checkSecure(url, what, this.#allowInsecure);
|
|
377
488
|
}
|
|
378
489
|
async #getJson(step, method, url, jsonBody) {
|
|
379
490
|
const headers = { Accept: "application/json" };
|
|
@@ -406,30 +517,63 @@ export class ClientAttestation {
|
|
|
406
517
|
}
|
|
407
518
|
}
|
|
408
519
|
}
|
|
409
|
-
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
410
520
|
function tokenResponse(res) {
|
|
411
521
|
if (!res.ok) {
|
|
412
522
|
throw new ClientAttestationError(`token endpoint returned HTTP ${res.status}`, {
|
|
413
523
|
status: res.status,
|
|
414
|
-
body: res.body,
|
|
524
|
+
body: snippet(res.body),
|
|
415
525
|
});
|
|
416
526
|
}
|
|
417
527
|
const parsed = jsonOk(res, { allowEmpty: true });
|
|
418
528
|
if (!parsed.access_token) {
|
|
419
|
-
|
|
529
|
+
// No body: a 2xx token response may hold the token under another name.
|
|
530
|
+
throw new ClientAttestationError("token endpoint returned no access_token");
|
|
420
531
|
}
|
|
421
532
|
return parsed;
|
|
422
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* How long to cache a token, in seconds: `expires_in` as a JSON number, or a string of decimal digits,
|
|
536
|
+
* capped at {@link MAX_TOKEN_LIFETIME_SECONDS}. Anything else - missing, null, not a number, not finite,
|
|
537
|
+
* zero or negative - is undefined, and the token is not cached, as with no `expires_in` at all.
|
|
538
|
+
*/
|
|
539
|
+
function tokenLifetime(expiresIn) {
|
|
540
|
+
let seconds;
|
|
541
|
+
if (typeof expiresIn === "number")
|
|
542
|
+
seconds = expiresIn;
|
|
543
|
+
else if (typeof expiresIn === "string" && /^[0-9]+(\.[0-9]+)?$/.test(expiresIn))
|
|
544
|
+
seconds = Number(expiresIn);
|
|
545
|
+
else
|
|
546
|
+
return undefined;
|
|
547
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
548
|
+
return undefined;
|
|
549
|
+
return Math.min(seconds, MAX_TOKEN_LIFETIME_SECONDS);
|
|
550
|
+
}
|
|
423
551
|
function isDpop(state) {
|
|
424
552
|
return state.tokenType.toLowerCase() === "dpop";
|
|
425
553
|
}
|
|
426
554
|
function scheme(state) {
|
|
427
555
|
return isDpop(state) ? "DPoP" : "Bearer";
|
|
428
556
|
}
|
|
429
|
-
/** `
|
|
557
|
+
/** A `DPoP-Nonce` value, or null when there is none or it is longer than {@link MAX_DPOP_NONCE_LENGTH}. */
|
|
558
|
+
function usableNonce(nonce) {
|
|
559
|
+
return nonce && nonce.length <= MAX_DPOP_NONCE_LENGTH ? nonce : null;
|
|
560
|
+
}
|
|
561
|
+
/** `url` as WHATWG URL parsing (and so fetch) serialises it, or as given when it does not parse. */
|
|
562
|
+
function parsedHref(url) {
|
|
563
|
+
try {
|
|
564
|
+
return new URL(url).href;
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return url;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* `scheme://host[:port]` of a URL as its `htu` names it ({@link htuParts}), the key DPoP nonces are
|
|
572
|
+
* remembered under. A URL that rule refuses is its own key.
|
|
573
|
+
*/
|
|
430
574
|
function originOf(url) {
|
|
431
575
|
try {
|
|
432
|
-
return
|
|
576
|
+
return htuParts(url)[0];
|
|
433
577
|
}
|
|
434
578
|
catch {
|
|
435
579
|
return url;
|
|
@@ -453,18 +597,65 @@ function replayable(body) {
|
|
|
453
597
|
}
|
|
454
598
|
/**
|
|
455
599
|
* RFC 9449 section 9: a resource asks for a nonce with 401 + `WWW-Authenticate: DPoP error="use_dpop_nonce"`;
|
|
456
|
-
* section 8 (and some resources) use a 400 JSON `{"error":"use_dpop_nonce"}`.
|
|
600
|
+
* section 8 (and some resources) use a 400 JSON `{"error":"use_dpop_nonce"}`. The 400 body is read from a
|
|
601
|
+
* clone, at most {@link MAX_NONCE_CHALLENGE_BODY} bytes and for at most `timeoutMs`, so the caller still
|
|
602
|
+
* gets the whole body.
|
|
457
603
|
*/
|
|
458
|
-
async function isNonceChallenge(res) {
|
|
604
|
+
async function isNonceChallenge(res, timeoutMs) {
|
|
459
605
|
if (res.status === 401) {
|
|
460
|
-
|
|
461
|
-
|
|
606
|
+
// fetch's Headers hands back the fields combined as one list (RFC 9110 section 5.3)
|
|
607
|
+
const challenge = res.headers.get("WWW-Authenticate");
|
|
608
|
+
return challenge !== null && isDpopNonceChallenge([challenge]);
|
|
462
609
|
}
|
|
463
610
|
if (res.status === 400) {
|
|
464
|
-
|
|
611
|
+
const body = await peekBody(res, MAX_NONCE_CHALLENGE_BODY, timeoutMs);
|
|
612
|
+
return body !== undefined && jsonError(body) === USE_DPOP_NONCE;
|
|
465
613
|
}
|
|
466
614
|
return false;
|
|
467
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* The text of `res`'s body read from a clone, or undefined when it is longer than `maxBytes`, fails, or is
|
|
618
|
+
* not complete within `timeoutMs`. The clone is cancelled once read, so no more than about `maxBytes` is
|
|
619
|
+
* buffered for the caller's copy.
|
|
620
|
+
*/
|
|
621
|
+
async function peekBody(res, maxBytes, timeoutMs) {
|
|
622
|
+
const reader = res.clone().body?.getReader();
|
|
623
|
+
if (!reader)
|
|
624
|
+
return "";
|
|
625
|
+
let timer;
|
|
626
|
+
const expired = new Promise((resolve) => {
|
|
627
|
+
timer = setTimeout(() => resolve(undefined), timeoutMs);
|
|
628
|
+
});
|
|
629
|
+
const chunks = [];
|
|
630
|
+
let total = 0;
|
|
631
|
+
try {
|
|
632
|
+
for (;;) {
|
|
633
|
+
const next = await Promise.race([reader.read(), expired]);
|
|
634
|
+
if (next === undefined)
|
|
635
|
+
return undefined;
|
|
636
|
+
if (next.done)
|
|
637
|
+
break;
|
|
638
|
+
total += next.value.byteLength;
|
|
639
|
+
if (total > maxBytes)
|
|
640
|
+
return undefined;
|
|
641
|
+
chunks.push(next.value);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
return undefined;
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
clearTimeout(timer);
|
|
649
|
+
reader.cancel().catch(() => undefined);
|
|
650
|
+
}
|
|
651
|
+
const all = new Uint8Array(total);
|
|
652
|
+
let offset = 0;
|
|
653
|
+
for (const chunk of chunks) {
|
|
654
|
+
all.set(chunk, offset);
|
|
655
|
+
offset += chunk.byteLength;
|
|
656
|
+
}
|
|
657
|
+
return utf8Decode(all);
|
|
658
|
+
}
|
|
468
659
|
/**
|
|
469
660
|
* Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
|
|
470
661
|
* bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
|
|
@@ -483,6 +674,37 @@ function sortKeys(value) {
|
|
|
483
674
|
}
|
|
484
675
|
return value;
|
|
485
676
|
}
|
|
677
|
+
/**
|
|
678
|
+
* The PoP method to use. `undefined`, `null` or `""` (unset) is the default; any other value must be
|
|
679
|
+
* exactly one of the two the draft names (case-sensitive), so a misspelt value can't quietly send a PoP
|
|
680
|
+
* JWT (vectors/pop-method.json).
|
|
681
|
+
*/
|
|
682
|
+
function checkPopMethod(popMethod) {
|
|
683
|
+
if (popMethod === undefined || popMethod === null || popMethod === "")
|
|
684
|
+
return POP_JWT;
|
|
685
|
+
const known = POP_METHODS.find((name) => name === popMethod);
|
|
686
|
+
if (known === undefined) {
|
|
687
|
+
throw new RangeError(`popMethod must be "${POP_JWT}" or "${DPOP_COMBINED}", got ${describe(popMethod)}`);
|
|
688
|
+
}
|
|
689
|
+
return known;
|
|
690
|
+
}
|
|
691
|
+
/** A value for an error message: JSON where that works, never a throw (BigInt, a cycle, a symbol). */
|
|
692
|
+
function describe(value) {
|
|
693
|
+
try {
|
|
694
|
+
const json = JSON.stringify(value);
|
|
695
|
+
if (json !== undefined)
|
|
696
|
+
return json;
|
|
697
|
+
}
|
|
698
|
+
catch {
|
|
699
|
+
// fall through
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
return typeof value === "bigint" ? `${value}n` : String(value);
|
|
703
|
+
}
|
|
704
|
+
catch {
|
|
705
|
+
return Object.prototype.toString.call(value);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
486
708
|
/** @internal */
|
|
487
709
|
export function normalizeBaseUrl(attesterUrl) {
|
|
488
710
|
let host = (attesterUrl ?? "").trim().replace(/\/+$/, "");
|