@idpartners/client-attestation-sdk 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -22
- package/dist/attester.d.ts +14 -6
- package/dist/attester.js +36 -12
- package/dist/builders.d.ts +11 -3
- package/dist/builders.js +157 -16
- package/dist/errors.d.ts +6 -2
- package/dist/errors.js +6 -2
- package/dist/evidence.d.ts +3 -1
- package/dist/evidence.js +46 -8
- package/dist/http.js +69 -14
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/issuance.d.ts +19 -5
- package/dist/issuance.js +38 -15
- package/dist/logging.js +41 -7
- package/dist/runtime.js +9 -0
- package/dist/signer.d.ts +8 -1
- package/dist/signer.js +32 -5
- package/dist/signing-key-pair.d.ts +6 -0
- package/dist/signing-key-pair.js +91 -5
- package/dist/svid-source.d.ts +13 -2
- package/dist/svid-source.js +204 -17
- package/dist/token-source.d.ts +28 -4
- package/dist/token-source.js +234 -47
- package/dist/validator/config.d.ts +11 -1
- package/dist/validator/config.js +77 -8
- package/dist/validator/index.d.ts +1 -1
- package/dist/validator/index.js +1 -1
- package/dist/validator/jwks.js +65 -20
- package/dist/validator/resource.d.ts +9 -1
- package/dist/validator/resource.js +57 -3
- package/dist/validator/strict.js +6 -1
- package/dist/validator/validator.js +42 -11
- package/package.json +1 -1
package/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,22 +20,38 @@
|
|
|
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";
|
|
23
|
+
import { checkHtu, DpopProofBuilder, htuParts, normalizeHtu } from "./builders.js";
|
|
24
24
|
import { 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
32
|
export const ATTESTATION_HEADER = "OAuth-Client-Attestation";
|
|
33
33
|
export const POP_HEADER = "OAuth-Client-Attestation-PoP";
|
|
34
34
|
/** Refresh this many seconds before the token actually expires, so a token is never used at the edge. */
|
|
35
35
|
export const DEFAULT_EXPIRY_SKEW_SECONDS = 30;
|
|
36
|
+
const POP_JWT = "attestation_pop_jwt";
|
|
37
|
+
const DPOP_COMBINED = "dpop_combined";
|
|
38
|
+
const POP_METHODS = [POP_JWT, DPOP_COMBINED];
|
|
39
|
+
const PIN_REQUIRED = "evidenceAudience is required: set it to the audience your attester expects the workload's " +
|
|
40
|
+
"identity token for, so the attester's discovery document can't choose it";
|
|
36
41
|
export { ClientAttestationError, TransportError } from "./errors.js";
|
|
37
42
|
const USE_DPOP_NONCE = "use_dpop_nonce";
|
|
38
43
|
const DPOP_NONCE_HEADER = "DPoP-Nonce";
|
|
44
|
+
/** At most this much of a 400 body is read to look for `use_dpop_nonce`; a longer body is not a challenge. */
|
|
45
|
+
const MAX_NONCE_CHALLENGE_BODY = 64 * 1024;
|
|
46
|
+
/** RFC 9449 nonces are short opaque strings: a longer `DPoP-Nonce` is ignored, as if there were none. */
|
|
47
|
+
const MAX_DPOP_NONCE_LENGTH = 1024;
|
|
48
|
+
/**
|
|
49
|
+
* At most this many origins' nonces are remembered; a new origin over the cap replaces the one stored
|
|
50
|
+
* longest ago (it costs that origin one challenge).
|
|
51
|
+
*/
|
|
52
|
+
const MAX_DPOP_NONCE_ORIGINS = 256;
|
|
53
|
+
/** A token is cached for at most this long (seconds), whatever `expires_in` the token endpoint gives. */
|
|
54
|
+
const MAX_TOKEN_LIFETIME_SECONDS = 24 * 60 * 60;
|
|
39
55
|
/** What the https check calls the URL a profile's call goes to, by step. */
|
|
40
56
|
const STEP_LABELS = {
|
|
41
57
|
discover: "attesterUrl",
|
|
@@ -46,7 +62,7 @@ const STEP_LABELS = {
|
|
|
46
62
|
* Gets and caches an access token using client attestation.
|
|
47
63
|
*
|
|
48
64
|
* `new ClientAttestation(attesterUrl, opts)`, or `new ClientAttestation(opts)` with `opts.metadata` (no
|
|
49
|
-
* discovery) or a custom `opts.profile`.
|
|
65
|
+
* discovery) or a custom `opts.profile`. A client that discovers its attester needs `opts.evidenceAudience`.
|
|
50
66
|
*
|
|
51
67
|
* @param attesterUrl the attester's host — `"pf.example.com"`, or a full base URL
|
|
52
68
|
* `"https://pf.example.com"`. Plain hostnames default to https. Optional when `metadata` or `profile`
|
|
@@ -70,8 +86,10 @@ export class ClientAttestation {
|
|
|
70
86
|
/** The token, its type and its bound key: always replaced together, never one without the others. */
|
|
71
87
|
#state = null;
|
|
72
88
|
#metadata;
|
|
89
|
+
#staticMetadata;
|
|
90
|
+
#evidenceAudience;
|
|
73
91
|
#refreshing = null;
|
|
74
|
-
/** The last `DPoP-Nonce` each origin returned. */
|
|
92
|
+
/** The last `DPoP-Nonce` each origin returned, oldest first. */
|
|
75
93
|
#nonces = new Map();
|
|
76
94
|
constructor(attesterUrl, opts = {}) {
|
|
77
95
|
if (typeof attesterUrl === "object" && attesterUrl !== null) {
|
|
@@ -81,14 +99,19 @@ export class ClientAttestation {
|
|
|
81
99
|
const url = attesterUrl ?? opts.attesterUrl;
|
|
82
100
|
this.#profile = opts.profile ?? new PingFederateProfile();
|
|
83
101
|
this.#metadata = opts.metadata ?? null;
|
|
102
|
+
this.#staticMetadata = this.#metadata !== null;
|
|
103
|
+
this.#evidenceAudience = opts.evidenceAudience;
|
|
84
104
|
this.#evidence = opts.evidence;
|
|
85
105
|
this.#authorizationDetails = opts.authorizationDetails;
|
|
86
106
|
this.#operator = opts.operator && Object.keys(opts.operator).length > 0 ? opts.operator : undefined;
|
|
87
107
|
this.#challengeAtToken = opts.challengeAtToken ?? true;
|
|
88
108
|
this.#algorithm = opts.algorithm ?? "ES256";
|
|
89
109
|
this.#skew = opts.expirySkewSeconds ?? DEFAULT_EXPIRY_SKEW_SECONDS;
|
|
110
|
+
if (!Number.isFinite(this.#skew) || this.#skew < 0) {
|
|
111
|
+
throw new RangeError(`expirySkewSeconds must be a finite number, not negative: ${this.#skew}`);
|
|
112
|
+
}
|
|
90
113
|
this.#timeoutMs = opts.timeoutMs ?? 20_000;
|
|
91
|
-
this.#popMethod = opts.popMethod
|
|
114
|
+
this.#popMethod = checkPopMethod(opts.popMethod);
|
|
92
115
|
this.#requireChallenge = opts.requireChallenge ?? true;
|
|
93
116
|
this.#allowInsecure = opts.allowInsecure ?? false;
|
|
94
117
|
this.#logger = opts.logger ?? NOOP_LOGGER;
|
|
@@ -121,16 +144,54 @@ export class ClientAttestation {
|
|
|
121
144
|
* - DPoP token: `{ Authorization: "DPoP <t>", DPoP: <proof> }` — a fresh proof signed by the key the
|
|
122
145
|
* token is bound to, for this `method` and `url`, carrying `ath` and the last `DPoP-Nonce` the
|
|
123
146
|
* url's origin returned, if any.
|
|
147
|
+
*
|
|
148
|
+
* `url` must be https, like every other URL the client sends a credential to (loopback hosts and
|
|
149
|
+
* `allowInsecure` excepted).
|
|
150
|
+
*
|
|
151
|
+
* Pass the exact URL your HTTP client sends: the proof is only valid for the `htu` it names. A url with
|
|
152
|
+
* a backslash, ASCII whitespace or a control character before its query or fragment is refused with a
|
|
153
|
+
* {@link ClientAttestationError}, because URL parsers disagree on where it goes. For a DPoP-bound token,
|
|
154
|
+
* so is a url that is not an absolute http(s) URL with a valid ASCII host, port and path; with
|
|
155
|
+
* `popMethod: "dpop_combined"` that is checked before the token is fetched. The path is signed as
|
|
156
|
+
* written, so pass `new URL(u).href` (what `fetch` sends) rather than a hand-written string: `fetch` may
|
|
157
|
+
* escape characters (a `{`, say) that the string leaves as they are. {@link fetch} does this for you.
|
|
124
158
|
*/
|
|
125
159
|
async requestHeaders(method, url, opts = {}) {
|
|
160
|
+
return this.#requestHeaders(method, url, opts);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* {@link requestHeaders}, with the proof carrying `nonce` when given (the nonce of the challenge being
|
|
164
|
+
* answered) instead of the origin's remembered one.
|
|
165
|
+
*/
|
|
166
|
+
async #requestHeaders(method, url, opts, nonce) {
|
|
167
|
+
this.#checkSecure(url, "resource URL");
|
|
168
|
+
// A client configured for DPoP checks the whole htu before fetching the token, so a URL with no htu
|
|
169
|
+
// never costs a mint and token request on a cold cache. Otherwise only what parsers disagree on is
|
|
170
|
+
// refused up front: a Bearer token carries no htu.
|
|
171
|
+
try {
|
|
172
|
+
if (this.#popMethod === DPOP_COMBINED)
|
|
173
|
+
normalizeHtu(url);
|
|
174
|
+
else
|
|
175
|
+
checkHtu(url);
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
throw new ClientAttestationError(err.message);
|
|
179
|
+
}
|
|
126
180
|
const state = await this.#current(opts);
|
|
127
181
|
const headers = { Authorization: `${scheme(state)} ${state.accessToken}` };
|
|
128
182
|
if (isDpop(state)) {
|
|
183
|
+
// Also for a DPoP token from a client not configured for DPoP (the AS chose the token type).
|
|
184
|
+
try {
|
|
185
|
+
normalizeHtu(url);
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
throw new ClientAttestationError(err.message);
|
|
189
|
+
}
|
|
129
190
|
headers[DPOP_HEADER] = await new DpopProofBuilder(state.key)
|
|
130
191
|
.method(method)
|
|
131
192
|
.uri(url)
|
|
132
193
|
.accessToken(state.accessToken)
|
|
133
|
-
.nonce(this.#nonces.get(originOf(url)) ?? null)
|
|
194
|
+
.nonce(nonce ?? this.#nonces.get(originOf(url)) ?? null)
|
|
134
195
|
.build();
|
|
135
196
|
}
|
|
136
197
|
return headers;
|
|
@@ -140,33 +201,47 @@ export class ClientAttestation {
|
|
|
140
201
|
* as global `fetch`. For a DPoP-bound token it adds a fresh proof and, when the resource answers with
|
|
141
202
|
* a `use_dpop_nonce` challenge and a `DPoP-Nonce`, resends ONCE with a proof carrying that nonce —
|
|
142
203
|
* unless the body is a one-shot stream, which cannot be resent (the challenge response is returned).
|
|
204
|
+
* Redirects are returned without following them, even if the input or init requests redirect following.
|
|
143
205
|
*/
|
|
144
206
|
async fetch(input, init = {}) {
|
|
145
207
|
const isRequest = typeof Request !== "undefined" && input instanceof Request;
|
|
146
|
-
|
|
208
|
+
// The proof names the URL as fetch parses it (WHATWG), which is where the request goes.
|
|
209
|
+
const url = isRequest ? input.url : parsedHref(String(input));
|
|
147
210
|
const method = (init.method ?? (isRequest ? input.method : "GET")).toUpperCase();
|
|
148
211
|
// A Request's own body is cloned so the retry can send it again.
|
|
149
212
|
const nextInput = () => (isRequest ? input.clone() : input);
|
|
150
213
|
let dpopBound = false;
|
|
151
|
-
const send = async () => {
|
|
214
|
+
const send = async (nonce) => {
|
|
152
215
|
const headers = new Headers(init.headers ?? (isRequest ? input.headers : undefined));
|
|
153
|
-
const auth =
|
|
216
|
+
const auth = nonce === undefined
|
|
217
|
+
? await this.requestHeaders(method, url)
|
|
218
|
+
: await this.#requestHeaders(method, url, {}, nonce);
|
|
154
219
|
dpopBound = DPOP_HEADER in auth;
|
|
155
220
|
for (const [name, value] of Object.entries(auth))
|
|
156
221
|
headers.set(name, value);
|
|
157
|
-
return fetch(nextInput(), { ...init, headers });
|
|
222
|
+
return fetch(nextInput(), { ...init, headers, redirect: "manual" });
|
|
158
223
|
};
|
|
159
224
|
const first = await send();
|
|
160
225
|
if (!dpopBound)
|
|
161
226
|
return first;
|
|
162
|
-
const nonce = first.headers.get(DPOP_NONCE_HEADER);
|
|
227
|
+
const nonce = usableNonce(first.headers.get(DPOP_NONCE_HEADER));
|
|
163
228
|
if (nonce)
|
|
164
|
-
this.#
|
|
165
|
-
if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
|
|
229
|
+
this.#rememberNonce(url, nonce);
|
|
230
|
+
if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first, this.#timeoutMs)))
|
|
166
231
|
return first;
|
|
167
232
|
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(url), status: first.status, at: "resource" });
|
|
168
233
|
await first.body?.cancel().catch(() => undefined);
|
|
169
|
-
|
|
234
|
+
// Answer this challenge with its own nonce, not one a concurrent response stored since.
|
|
235
|
+
return send(nonce);
|
|
236
|
+
}
|
|
237
|
+
/** Store `nonce` as the origin's latest, forgetting the origin stored longest ago over the cap. */
|
|
238
|
+
#rememberNonce(url, nonce) {
|
|
239
|
+
const key = originOf(url);
|
|
240
|
+
this.#nonces.delete(key);
|
|
241
|
+
if (this.#nonces.size >= MAX_DPOP_NONCE_ORIGINS) {
|
|
242
|
+
this.#nonces.delete(this.#nonces.keys().next().value);
|
|
243
|
+
}
|
|
244
|
+
this.#nonces.set(key, nonce);
|
|
170
245
|
}
|
|
171
246
|
/**
|
|
172
247
|
* The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
|
|
@@ -200,9 +275,10 @@ export class ClientAttestation {
|
|
|
200
275
|
/** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
|
|
201
276
|
async #current(opts) {
|
|
202
277
|
const state = this.#state;
|
|
203
|
-
|
|
278
|
+
const now = monotonicMs();
|
|
279
|
+
if (!opts.forceRefresh && state && now < state.expiresAt) {
|
|
204
280
|
emit(this.#logger, "debug", "token.cache_hit", {
|
|
205
|
-
expires_in: Math.max(0, Math.round((state.expiresAt -
|
|
281
|
+
expires_in: Math.max(0, Math.round((state.expiresAt - now) / 1000)),
|
|
206
282
|
});
|
|
207
283
|
return state;
|
|
208
284
|
}
|
|
@@ -218,22 +294,33 @@ export class ClientAttestation {
|
|
|
218
294
|
}
|
|
219
295
|
async #refresh() {
|
|
220
296
|
const { response, key } = await this.#runFlow();
|
|
221
|
-
const
|
|
297
|
+
const lifetime = tokenLifetime(response.expires_in);
|
|
222
298
|
const state = {
|
|
223
299
|
accessToken: response.access_token,
|
|
224
300
|
tokenType: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
|
|
225
301
|
key,
|
|
226
|
-
expiresAt:
|
|
302
|
+
expiresAt: lifetime !== undefined ? monotonicMs() + (lifetime - this.#skew) * 1000 : 0,
|
|
227
303
|
};
|
|
228
304
|
this.#state = state;
|
|
229
305
|
return state;
|
|
230
306
|
}
|
|
231
307
|
async #runFlow() {
|
|
308
|
+
// The constructor refused anything else. A value that still isn't one of the two fails here, before
|
|
309
|
+
// any request, rather than falling back to a PoP JWT.
|
|
310
|
+
if (!POP_METHODS.includes(this.#popMethod)) {
|
|
311
|
+
throw new ClientAttestationError(`unsupported popMethod ${describe(this.#popMethod)}`);
|
|
312
|
+
}
|
|
313
|
+
if (!this.#evidenceAudience && !this.#staticMetadata)
|
|
314
|
+
throw new ClientAttestationError(PIN_REQUIRED);
|
|
232
315
|
const meta = await this.attesterMetadata();
|
|
233
316
|
const audience = meta.evidenceAudience;
|
|
234
317
|
if (!audience) {
|
|
235
318
|
throw new ClientAttestationError("attester discovery document has no evidence_audience");
|
|
236
319
|
}
|
|
320
|
+
if (this.#evidenceAudience && audience !== this.#evidenceAudience) {
|
|
321
|
+
throw new ClientAttestationError(`the attester asks for evidence for ${JSON.stringify(audience)}, but evidenceAudience is ` +
|
|
322
|
+
JSON.stringify(this.#evidenceAudience));
|
|
323
|
+
}
|
|
237
324
|
const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
|
|
238
325
|
const svid = await evidenceSource.fetch(audience);
|
|
239
326
|
emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
|
|
@@ -268,26 +355,27 @@ export class ClientAttestation {
|
|
|
268
355
|
const tokenChallenge = this.#challengeAtToken ? challenge : undefined;
|
|
269
356
|
const credential = new ClientAttestationCredential(attestation, instanceKey);
|
|
270
357
|
const obtained = (response) => {
|
|
271
|
-
const
|
|
358
|
+
const lifetime = tokenLifetime(response.expires_in);
|
|
272
359
|
emit(this.#logger, "info", "token.obtained", {
|
|
273
360
|
host: hostOf(tokenEndpoint),
|
|
274
361
|
client_id: clientId,
|
|
275
362
|
token_type: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
|
|
276
|
-
expires_in:
|
|
363
|
+
expires_in: lifetime !== undefined ? Math.trunc(lifetime) : undefined,
|
|
277
364
|
pop_method: this.#popMethod,
|
|
278
365
|
});
|
|
279
366
|
return { response, key: instanceKey };
|
|
280
367
|
};
|
|
281
|
-
if (this.#popMethod
|
|
368
|
+
if (this.#popMethod === POP_JWT) {
|
|
282
369
|
const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
|
|
283
370
|
return obtained(this.#tokenResponse(tokenEndpoint, await this.#postToken(tokenEndpoint, headers)));
|
|
284
371
|
}
|
|
285
|
-
// DPoP combined
|
|
286
|
-
// (400 use_dpop_nonce + DPoP-Nonce), retry once
|
|
372
|
+
// DPoP combined, the only other value (checked at the top of the flow): the attester challenge rides
|
|
373
|
+
// as the DPoP nonce. If the AS demands its own nonce (400 use_dpop_nonce + DPoP-Nonce), retry once
|
|
374
|
+
// with that — the server's demand wins.
|
|
287
375
|
let res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, tokenChallenge ?? null));
|
|
288
|
-
const serverNonce = res.headers.get(DPOP_NONCE_HEADER);
|
|
376
|
+
const serverNonce = usableNonce(res.headers.get(DPOP_NONCE_HEADER));
|
|
289
377
|
if (serverNonce)
|
|
290
|
-
this.#
|
|
378
|
+
this.#rememberNonce(tokenEndpoint, serverNonce);
|
|
291
379
|
if (res.status === 400 && serverNonce && jsonError(res.body) === USE_DPOP_NONCE) {
|
|
292
380
|
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
|
|
293
381
|
res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
|
|
@@ -361,18 +449,7 @@ export class ClientAttestation {
|
|
|
361
449
|
}
|
|
362
450
|
/** Refuse a non-https URL unless it is loopback or the caller passed `allowInsecure`. */
|
|
363
451
|
#checkSecure(url, what) {
|
|
364
|
-
|
|
365
|
-
return;
|
|
366
|
-
let parsed;
|
|
367
|
-
try {
|
|
368
|
-
parsed = new URL(url);
|
|
369
|
-
}
|
|
370
|
-
catch {
|
|
371
|
-
throw new ClientAttestationError(`${what} is not a valid URL: ${url}`);
|
|
372
|
-
}
|
|
373
|
-
if (parsed.protocol === "https:" || LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
|
|
374
|
-
return;
|
|
375
|
-
throw new ClientAttestationError(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
|
|
452
|
+
checkSecure(url, what, this.#allowInsecure);
|
|
376
453
|
}
|
|
377
454
|
async #getJson(step, method, url, jsonBody) {
|
|
378
455
|
const headers = { Accept: "application/json" };
|
|
@@ -405,30 +482,63 @@ export class ClientAttestation {
|
|
|
405
482
|
}
|
|
406
483
|
}
|
|
407
484
|
}
|
|
408
|
-
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
409
485
|
function tokenResponse(res) {
|
|
410
486
|
if (!res.ok) {
|
|
411
487
|
throw new ClientAttestationError(`token endpoint returned HTTP ${res.status}`, {
|
|
412
488
|
status: res.status,
|
|
413
|
-
body: res.body,
|
|
489
|
+
body: snippet(res.body),
|
|
414
490
|
});
|
|
415
491
|
}
|
|
416
492
|
const parsed = jsonOk(res, { allowEmpty: true });
|
|
417
493
|
if (!parsed.access_token) {
|
|
418
|
-
|
|
494
|
+
// No body: a 2xx token response may hold the token under another name.
|
|
495
|
+
throw new ClientAttestationError("token endpoint returned no access_token");
|
|
419
496
|
}
|
|
420
497
|
return parsed;
|
|
421
498
|
}
|
|
499
|
+
/**
|
|
500
|
+
* How long to cache a token, in seconds: `expires_in` as a JSON number, or a string of decimal digits,
|
|
501
|
+
* capped at {@link MAX_TOKEN_LIFETIME_SECONDS}. Anything else - missing, null, not a number, not finite,
|
|
502
|
+
* zero or negative - is undefined, and the token is not cached, as with no `expires_in` at all.
|
|
503
|
+
*/
|
|
504
|
+
function tokenLifetime(expiresIn) {
|
|
505
|
+
let seconds;
|
|
506
|
+
if (typeof expiresIn === "number")
|
|
507
|
+
seconds = expiresIn;
|
|
508
|
+
else if (typeof expiresIn === "string" && /^[0-9]+(\.[0-9]+)?$/.test(expiresIn))
|
|
509
|
+
seconds = Number(expiresIn);
|
|
510
|
+
else
|
|
511
|
+
return undefined;
|
|
512
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
513
|
+
return undefined;
|
|
514
|
+
return Math.min(seconds, MAX_TOKEN_LIFETIME_SECONDS);
|
|
515
|
+
}
|
|
422
516
|
function isDpop(state) {
|
|
423
517
|
return state.tokenType.toLowerCase() === "dpop";
|
|
424
518
|
}
|
|
425
519
|
function scheme(state) {
|
|
426
520
|
return isDpop(state) ? "DPoP" : "Bearer";
|
|
427
521
|
}
|
|
428
|
-
/** `
|
|
522
|
+
/** A `DPoP-Nonce` value, or null when there is none or it is longer than {@link MAX_DPOP_NONCE_LENGTH}. */
|
|
523
|
+
function usableNonce(nonce) {
|
|
524
|
+
return nonce && nonce.length <= MAX_DPOP_NONCE_LENGTH ? nonce : null;
|
|
525
|
+
}
|
|
526
|
+
/** `url` as WHATWG URL parsing (and so fetch) serialises it, or as given when it does not parse. */
|
|
527
|
+
function parsedHref(url) {
|
|
528
|
+
try {
|
|
529
|
+
return new URL(url).href;
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
return url;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* `scheme://host[:port]` of a URL as its `htu` names it ({@link htuParts}), the key DPoP nonces are
|
|
537
|
+
* remembered under. A URL that rule refuses is its own key.
|
|
538
|
+
*/
|
|
429
539
|
function originOf(url) {
|
|
430
540
|
try {
|
|
431
|
-
return
|
|
541
|
+
return htuParts(url)[0];
|
|
432
542
|
}
|
|
433
543
|
catch {
|
|
434
544
|
return url;
|
|
@@ -452,18 +562,64 @@ function replayable(body) {
|
|
|
452
562
|
}
|
|
453
563
|
/**
|
|
454
564
|
* RFC 9449 section 9: a resource asks for a nonce with 401 + `WWW-Authenticate: DPoP error="use_dpop_nonce"`;
|
|
455
|
-
* section 8 (and some resources) use a 400 JSON `{"error":"use_dpop_nonce"}`.
|
|
565
|
+
* section 8 (and some resources) use a 400 JSON `{"error":"use_dpop_nonce"}`. The 400 body is read from a
|
|
566
|
+
* clone, at most {@link MAX_NONCE_CHALLENGE_BODY} bytes and for at most `timeoutMs`, so the caller still
|
|
567
|
+
* gets the whole body.
|
|
456
568
|
*/
|
|
457
|
-
async function isNonceChallenge(res) {
|
|
569
|
+
async function isNonceChallenge(res, timeoutMs) {
|
|
458
570
|
if (res.status === 401) {
|
|
459
571
|
const challenge = res.headers.get("WWW-Authenticate") ?? "";
|
|
460
572
|
return /(^|,)\s*DPoP\b/i.test(challenge) && /error\s*=\s*"?use_dpop_nonce"?/i.test(challenge);
|
|
461
573
|
}
|
|
462
574
|
if (res.status === 400) {
|
|
463
|
-
|
|
575
|
+
const body = await peekBody(res, MAX_NONCE_CHALLENGE_BODY, timeoutMs);
|
|
576
|
+
return body !== undefined && jsonError(body) === USE_DPOP_NONCE;
|
|
464
577
|
}
|
|
465
578
|
return false;
|
|
466
579
|
}
|
|
580
|
+
/**
|
|
581
|
+
* The text of `res`'s body read from a clone, or undefined when it is longer than `maxBytes`, fails, or is
|
|
582
|
+
* not complete within `timeoutMs`. The clone is cancelled once read, so no more than about `maxBytes` is
|
|
583
|
+
* buffered for the caller's copy.
|
|
584
|
+
*/
|
|
585
|
+
async function peekBody(res, maxBytes, timeoutMs) {
|
|
586
|
+
const reader = res.clone().body?.getReader();
|
|
587
|
+
if (!reader)
|
|
588
|
+
return "";
|
|
589
|
+
let timer;
|
|
590
|
+
const expired = new Promise((resolve) => {
|
|
591
|
+
timer = setTimeout(() => resolve(undefined), timeoutMs);
|
|
592
|
+
});
|
|
593
|
+
const chunks = [];
|
|
594
|
+
let total = 0;
|
|
595
|
+
try {
|
|
596
|
+
for (;;) {
|
|
597
|
+
const next = await Promise.race([reader.read(), expired]);
|
|
598
|
+
if (next === undefined)
|
|
599
|
+
return undefined;
|
|
600
|
+
if (next.done)
|
|
601
|
+
break;
|
|
602
|
+
total += next.value.byteLength;
|
|
603
|
+
if (total > maxBytes)
|
|
604
|
+
return undefined;
|
|
605
|
+
chunks.push(next.value);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch {
|
|
609
|
+
return undefined;
|
|
610
|
+
}
|
|
611
|
+
finally {
|
|
612
|
+
clearTimeout(timer);
|
|
613
|
+
reader.cancel().catch(() => undefined);
|
|
614
|
+
}
|
|
615
|
+
const all = new Uint8Array(total);
|
|
616
|
+
let offset = 0;
|
|
617
|
+
for (const chunk of chunks) {
|
|
618
|
+
all.set(chunk, offset);
|
|
619
|
+
offset += chunk.byteLength;
|
|
620
|
+
}
|
|
621
|
+
return utf8Decode(all);
|
|
622
|
+
}
|
|
467
623
|
/**
|
|
468
624
|
* Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
|
|
469
625
|
* bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
|
|
@@ -482,6 +638,37 @@ function sortKeys(value) {
|
|
|
482
638
|
}
|
|
483
639
|
return value;
|
|
484
640
|
}
|
|
641
|
+
/**
|
|
642
|
+
* The PoP method to use. `undefined`, `null` or `""` (unset) is the default; any other value must be
|
|
643
|
+
* exactly one of the two the draft names (case-sensitive), so a misspelt value can't quietly send a PoP
|
|
644
|
+
* JWT (vectors/pop-method.json).
|
|
645
|
+
*/
|
|
646
|
+
function checkPopMethod(popMethod) {
|
|
647
|
+
if (popMethod === undefined || popMethod === null || popMethod === "")
|
|
648
|
+
return POP_JWT;
|
|
649
|
+
const known = POP_METHODS.find((name) => name === popMethod);
|
|
650
|
+
if (known === undefined) {
|
|
651
|
+
throw new RangeError(`popMethod must be "${POP_JWT}" or "${DPOP_COMBINED}", got ${describe(popMethod)}`);
|
|
652
|
+
}
|
|
653
|
+
return known;
|
|
654
|
+
}
|
|
655
|
+
/** A value for an error message: JSON where that works, never a throw (BigInt, a cycle, a symbol). */
|
|
656
|
+
function describe(value) {
|
|
657
|
+
try {
|
|
658
|
+
const json = JSON.stringify(value);
|
|
659
|
+
if (json !== undefined)
|
|
660
|
+
return json;
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
// fall through
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
return typeof value === "bigint" ? `${value}n` : String(value);
|
|
667
|
+
}
|
|
668
|
+
catch {
|
|
669
|
+
return Object.prototype.toString.call(value);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
485
672
|
/** @internal */
|
|
486
673
|
export function normalizeBaseUrl(attesterUrl) {
|
|
487
674
|
let host = (attesterUrl ?? "").trim().replace(/\/+$/, "");
|
|
@@ -15,7 +15,11 @@ export interface IntrospectionConfig {
|
|
|
15
15
|
endpoint: string;
|
|
16
16
|
clientId: string;
|
|
17
17
|
clientSecret: string;
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Defaults to `"client_secret_basic"`, also when `null` or `""` (so an unset environment variable can be
|
|
20
|
+
* passed straight in). The validator refuses any other value than the two {@link IntrospectionAuthMethod}s,
|
|
21
|
+
* so a typo can't move the secret into the form body.
|
|
22
|
+
*/
|
|
19
23
|
authMethod?: IntrospectionAuthMethod;
|
|
20
24
|
}
|
|
21
25
|
/**
|
|
@@ -31,6 +35,10 @@ export interface IntrospectionConfig {
|
|
|
31
35
|
* An explicitly empty `acceptedAlgorithms` or `acceptedTokenTypes` accepts nothing, and `leewaySeconds: 0`
|
|
32
36
|
* allows no clock skew: only an omitted field takes the default. `none` and the `HS*` algorithms are
|
|
33
37
|
* refused even when listed (see validation/SEMANTICS.md).
|
|
38
|
+
*
|
|
39
|
+
* `jwksUri` and the introspection `endpoint` must use https unless their host is loopback (`localhost`,
|
|
40
|
+
* `127.0.0.1`, `::1`) or `allowInsecure` is set: over plain http anyone on the path could serve their own
|
|
41
|
+
* keys, or read the resource server's client secret.
|
|
34
42
|
*/
|
|
35
43
|
export interface ValidatorConfig {
|
|
36
44
|
issuer: string;
|
|
@@ -45,6 +53,8 @@ export interface ValidatorConfig {
|
|
|
45
53
|
introspection?: IntrospectionConfig;
|
|
46
54
|
/** Defaults to {@link DEFAULT_TOKEN_TYPES}; `null` disables the `typ` check. */
|
|
47
55
|
acceptedTokenTypes?: string[] | null;
|
|
56
|
+
/** Allow a plain-http `jwksUri` and introspection `endpoint` on non-loopback hosts. Defaults to `false`. */
|
|
57
|
+
allowInsecure?: boolean;
|
|
48
58
|
}
|
|
49
59
|
/** A {@link ValidatorConfig} with every optional field defaulted. */
|
|
50
60
|
export interface ResolvedConfig {
|
package/dist/validator/config.js
CHANGED
|
@@ -33,8 +33,37 @@ export function normaliseType(value) {
|
|
|
33
33
|
const lower = value.toLowerCase();
|
|
34
34
|
return lower.startsWith("application/") ? lower.slice("application/".length) : lower;
|
|
35
35
|
}
|
|
36
|
+
const AUTH_METHODS = ["client_secret_basic", "client_secret_post"];
|
|
37
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
38
|
+
/** Refuse a non-https URL unless it is loopback or `allowInsecure` is set: the rule the client applies. */
|
|
39
|
+
function checkSecure(url, what, allowInsecure) {
|
|
40
|
+
if (allowInsecure)
|
|
41
|
+
return;
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = new URL(url);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new Error(`${what} is not a valid URL: ${url}`);
|
|
48
|
+
}
|
|
49
|
+
if (parsed.protocol === "https:" || LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase()))
|
|
50
|
+
return;
|
|
51
|
+
throw new Error(`${what} must use https: ${url} (loopback hosts are exempt; pass allowInsecure: true to override)`);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* A list option as a copy, refusing anything but an array of strings. JavaScript callers aren't held to
|
|
55
|
+
* the types, and a bare string would otherwise spread into its characters.
|
|
56
|
+
*/
|
|
57
|
+
function stringList(value, what) {
|
|
58
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) {
|
|
59
|
+
throw new Error(`${what} must be an array of strings`);
|
|
60
|
+
}
|
|
61
|
+
return [...value];
|
|
62
|
+
}
|
|
36
63
|
/**
|
|
37
|
-
* Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`).
|
|
64
|
+
* Normalize a caller-supplied config, applying defaults (mirrors Python's `ValidatorConfig`). The result
|
|
65
|
+
* shares nothing mutable with `config`: a caller reusing or changing its config object afterwards (its
|
|
66
|
+
* arrays, its JWKS, its introspection settings) can't change a live validator.
|
|
38
67
|
*
|
|
39
68
|
* @internal
|
|
40
69
|
*/
|
|
@@ -42,20 +71,60 @@ export function resolveConfig(config) {
|
|
|
42
71
|
if (!config.issuer) {
|
|
43
72
|
throw new Error("issuer is required");
|
|
44
73
|
}
|
|
45
|
-
const audiences = typeof config.audiences === "string" ? [config.audiences] :
|
|
74
|
+
const audiences = typeof config.audiences === "string" ? [config.audiences] : stringList(config.audiences ?? [], "audiences");
|
|
46
75
|
if (audiences.length === 0) {
|
|
47
76
|
throw new Error("at least one audience is required");
|
|
48
77
|
}
|
|
78
|
+
// NaN would make every exp/nbf/iat comparison false (no time check at all), and "60" would concatenate
|
|
79
|
+
const leeway = config.leewaySeconds ?? 60;
|
|
80
|
+
if (typeof leeway !== "number" || !Number.isSafeInteger(leeway) || leeway < 0) {
|
|
81
|
+
throw new Error("leewaySeconds must be a non-negative integer");
|
|
82
|
+
}
|
|
83
|
+
const allowInsecure = config.allowInsecure ?? false;
|
|
84
|
+
if (typeof allowInsecure !== "boolean") {
|
|
85
|
+
throw new Error("allowInsecure must be a boolean");
|
|
86
|
+
}
|
|
87
|
+
if (config.jwksUri) {
|
|
88
|
+
checkSecure(config.jwksUri, "jwksUri", allowInsecure);
|
|
89
|
+
}
|
|
90
|
+
if (config.introspection?.endpoint) {
|
|
91
|
+
checkSecure(config.introspection.endpoint, "introspection endpoint", allowInsecure);
|
|
92
|
+
}
|
|
93
|
+
// JavaScript callers aren't held to the union type: anything else used to be taken as client_secret_post.
|
|
94
|
+
// Unset (undefined, null or "") is the default, client_secret_basic.
|
|
95
|
+
const authMethod = config.introspection?.authMethod;
|
|
96
|
+
const unset = authMethod == null || authMethod === "";
|
|
97
|
+
if (config.introspection != null && !unset && !AUTH_METHODS.includes(authMethod)) {
|
|
98
|
+
// never echo the value: a secret passed in the wrong field would end up in the message
|
|
99
|
+
throw new Error(`introspection authMethod must be "client_secret_basic" or "client_secret_post", got ${describe(authMethod)}`);
|
|
100
|
+
}
|
|
101
|
+
if (config.introspection != null) {
|
|
102
|
+
for (const field of ["clientId", "clientSecret"]) {
|
|
103
|
+
const value = config.introspection[field];
|
|
104
|
+
if (typeof value !== "string" || value === "") {
|
|
105
|
+
throw new Error(`introspection ${field} must be a non-empty string, got ${value === "" ? "an empty string" : describe(value)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
49
109
|
const types = config.acceptedTokenTypes === undefined ? DEFAULT_TOKEN_TYPES : config.acceptedTokenTypes;
|
|
50
110
|
return {
|
|
51
111
|
issuer: config.issuer,
|
|
52
112
|
audiences,
|
|
53
|
-
|
|
113
|
+
// the JWKS provider keeps each JWK and imports it again for each algorithm a token names
|
|
114
|
+
jwks: config.jwks == null ? undefined : structuredClone(config.jwks),
|
|
54
115
|
jwksUri: config.jwksUri,
|
|
55
|
-
requiredScopes:
|
|
56
|
-
acceptedAlgorithms:
|
|
57
|
-
leewaySeconds:
|
|
58
|
-
introspection: config.introspection
|
|
59
|
-
|
|
116
|
+
requiredScopes: stringList(config.requiredScopes ?? [], "requiredScopes"),
|
|
117
|
+
acceptedAlgorithms: stringList(config.acceptedAlgorithms ?? DEFAULT_ALGORITHMS, "acceptedAlgorithms"),
|
|
118
|
+
leewaySeconds: leeway,
|
|
119
|
+
introspection: config.introspection == null
|
|
120
|
+
? undefined
|
|
121
|
+
: { ...config.introspection, authMethod: config.introspection.authMethod || "client_secret_basic" },
|
|
122
|
+
acceptedTokenTypes: types === null ? null : stringList(types, "acceptedTokenTypes").map(normaliseType),
|
|
60
123
|
};
|
|
61
124
|
}
|
|
125
|
+
/** A value's type and size, never its content (it may be a secret). */
|
|
126
|
+
function describe(value) {
|
|
127
|
+
if (typeof value === "string")
|
|
128
|
+
return `a string of ${[...value].length} characters`;
|
|
129
|
+
return value === null ? "null" : `a ${typeof value}`;
|
|
130
|
+
}
|
|
@@ -16,4 +16,4 @@ export type { ValidationError } from "./errors.js";
|
|
|
16
16
|
export * as errors from "./errors.js";
|
|
17
17
|
export { ProtectedResource, bearerToken } from "./resource.js";
|
|
18
18
|
export type { ProtectedResourceMetadata, AuthDecision } from "./resource.js";
|
|
19
|
-
export declare const VALIDATOR_VERSION = "1.
|
|
19
|
+
export declare const VALIDATOR_VERSION = "1.2.0";
|
package/dist/validator/index.js
CHANGED
|
@@ -9,4 +9,4 @@ export { AccessTokenValidator } from "./validator.js";
|
|
|
9
9
|
export { DEFAULT_ALGORITHMS, DEFAULT_TOKEN_TYPES } from "./config.js";
|
|
10
10
|
export * as errors from "./errors.js";
|
|
11
11
|
export { ProtectedResource, bearerToken } from "./resource.js";
|
|
12
|
-
export const VALIDATOR_VERSION = "1.
|
|
12
|
+
export const VALIDATOR_VERSION = "1.2.0";
|