@idpartners/client-attestation-sdk 1.0.1 → 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 +27 -4
- package/dist/token-source.js +232 -46
- 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 +8 -0
- package/dist/validator/resource.js +50 -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;
|
|
@@ -144,14 +205,17 @@ export class ClientAttestation {
|
|
|
144
205
|
*/
|
|
145
206
|
async fetch(input, init = {}) {
|
|
146
207
|
const isRequest = typeof Request !== "undefined" && input instanceof Request;
|
|
147
|
-
|
|
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));
|
|
148
210
|
const method = (init.method ?? (isRequest ? input.method : "GET")).toUpperCase();
|
|
149
211
|
// A Request's own body is cloned so the retry can send it again.
|
|
150
212
|
const nextInput = () => (isRequest ? input.clone() : input);
|
|
151
213
|
let dpopBound = false;
|
|
152
|
-
const send = async () => {
|
|
214
|
+
const send = async (nonce) => {
|
|
153
215
|
const headers = new Headers(init.headers ?? (isRequest ? input.headers : undefined));
|
|
154
|
-
const auth =
|
|
216
|
+
const auth = nonce === undefined
|
|
217
|
+
? await this.requestHeaders(method, url)
|
|
218
|
+
: await this.#requestHeaders(method, url, {}, nonce);
|
|
155
219
|
dpopBound = DPOP_HEADER in auth;
|
|
156
220
|
for (const [name, value] of Object.entries(auth))
|
|
157
221
|
headers.set(name, value);
|
|
@@ -160,14 +224,24 @@ export class ClientAttestation {
|
|
|
160
224
|
const first = await send();
|
|
161
225
|
if (!dpopBound)
|
|
162
226
|
return first;
|
|
163
|
-
const nonce = first.headers.get(DPOP_NONCE_HEADER);
|
|
227
|
+
const nonce = usableNonce(first.headers.get(DPOP_NONCE_HEADER));
|
|
164
228
|
if (nonce)
|
|
165
|
-
this.#
|
|
166
|
-
if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first)))
|
|
229
|
+
this.#rememberNonce(url, nonce);
|
|
230
|
+
if (!nonce || !replayable(init.body) || !(await isNonceChallenge(first, this.#timeoutMs)))
|
|
167
231
|
return first;
|
|
168
232
|
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(url), status: first.status, at: "resource" });
|
|
169
233
|
await first.body?.cancel().catch(() => undefined);
|
|
170
|
-
|
|
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);
|
|
171
245
|
}
|
|
172
246
|
/**
|
|
173
247
|
* The attester's discovery document (PingFederate: `/.well-known/client-attester`), fetched once and
|
|
@@ -201,9 +275,10 @@ export class ClientAttestation {
|
|
|
201
275
|
/** The cached token state, or a freshly minted one. Concurrent callers share one refresh. */
|
|
202
276
|
async #current(opts) {
|
|
203
277
|
const state = this.#state;
|
|
204
|
-
|
|
278
|
+
const now = monotonicMs();
|
|
279
|
+
if (!opts.forceRefresh && state && now < state.expiresAt) {
|
|
205
280
|
emit(this.#logger, "debug", "token.cache_hit", {
|
|
206
|
-
expires_in: Math.max(0, Math.round((state.expiresAt -
|
|
281
|
+
expires_in: Math.max(0, Math.round((state.expiresAt - now) / 1000)),
|
|
207
282
|
});
|
|
208
283
|
return state;
|
|
209
284
|
}
|
|
@@ -219,22 +294,33 @@ export class ClientAttestation {
|
|
|
219
294
|
}
|
|
220
295
|
async #refresh() {
|
|
221
296
|
const { response, key } = await this.#runFlow();
|
|
222
|
-
const
|
|
297
|
+
const lifetime = tokenLifetime(response.expires_in);
|
|
223
298
|
const state = {
|
|
224
299
|
accessToken: response.access_token,
|
|
225
300
|
tokenType: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
|
|
226
301
|
key,
|
|
227
|
-
expiresAt:
|
|
302
|
+
expiresAt: lifetime !== undefined ? monotonicMs() + (lifetime - this.#skew) * 1000 : 0,
|
|
228
303
|
};
|
|
229
304
|
this.#state = state;
|
|
230
305
|
return state;
|
|
231
306
|
}
|
|
232
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);
|
|
233
315
|
const meta = await this.attesterMetadata();
|
|
234
316
|
const audience = meta.evidenceAudience;
|
|
235
317
|
if (!audience) {
|
|
236
318
|
throw new ClientAttestationError("attester discovery document has no evidence_audience");
|
|
237
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
|
+
}
|
|
238
324
|
const evidenceSource = this.#evidence ?? (await autoDetectEvidence());
|
|
239
325
|
const svid = await evidenceSource.fetch(audience);
|
|
240
326
|
emit(this.#logger, "debug", "evidence.obtained", { source: evidenceSource.constructor?.name });
|
|
@@ -269,26 +355,27 @@ export class ClientAttestation {
|
|
|
269
355
|
const tokenChallenge = this.#challengeAtToken ? challenge : undefined;
|
|
270
356
|
const credential = new ClientAttestationCredential(attestation, instanceKey);
|
|
271
357
|
const obtained = (response) => {
|
|
272
|
-
const
|
|
358
|
+
const lifetime = tokenLifetime(response.expires_in);
|
|
273
359
|
emit(this.#logger, "info", "token.obtained", {
|
|
274
360
|
host: hostOf(tokenEndpoint),
|
|
275
361
|
client_id: clientId,
|
|
276
362
|
token_type: typeof response.token_type === "string" && response.token_type ? response.token_type : "Bearer",
|
|
277
|
-
expires_in:
|
|
363
|
+
expires_in: lifetime !== undefined ? Math.trunc(lifetime) : undefined,
|
|
278
364
|
pop_method: this.#popMethod,
|
|
279
365
|
});
|
|
280
366
|
return { response, key: instanceKey };
|
|
281
367
|
};
|
|
282
|
-
if (this.#popMethod
|
|
368
|
+
if (this.#popMethod === POP_JWT) {
|
|
283
369
|
const headers = await credential.popHeaders(clientId, popAudience, tokenChallenge ?? null);
|
|
284
370
|
return obtained(this.#tokenResponse(tokenEndpoint, await this.#postToken(tokenEndpoint, headers)));
|
|
285
371
|
}
|
|
286
|
-
// DPoP combined
|
|
287
|
-
// (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.
|
|
288
375
|
let res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, tokenChallenge ?? null));
|
|
289
|
-
const serverNonce = res.headers.get(DPOP_NONCE_HEADER);
|
|
376
|
+
const serverNonce = usableNonce(res.headers.get(DPOP_NONCE_HEADER));
|
|
290
377
|
if (serverNonce)
|
|
291
|
-
this.#
|
|
378
|
+
this.#rememberNonce(tokenEndpoint, serverNonce);
|
|
292
379
|
if (res.status === 400 && serverNonce && jsonError(res.body) === USE_DPOP_NONCE) {
|
|
293
380
|
emit(this.#logger, "info", "dpop.nonce_retry", { host: hostOf(tokenEndpoint), status: res.status, at: "token_endpoint" });
|
|
294
381
|
res = await this.#postToken(tokenEndpoint, await credential.dpopHeaders("POST", tokenEndpoint, serverNonce));
|
|
@@ -362,18 +449,7 @@ export class ClientAttestation {
|
|
|
362
449
|
}
|
|
363
450
|
/** Refuse a non-https URL unless it is loopback or the caller passed `allowInsecure`. */
|
|
364
451
|
#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)`);
|
|
452
|
+
checkSecure(url, what, this.#allowInsecure);
|
|
377
453
|
}
|
|
378
454
|
async #getJson(step, method, url, jsonBody) {
|
|
379
455
|
const headers = { Accept: "application/json" };
|
|
@@ -406,30 +482,63 @@ export class ClientAttestation {
|
|
|
406
482
|
}
|
|
407
483
|
}
|
|
408
484
|
}
|
|
409
|
-
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
410
485
|
function tokenResponse(res) {
|
|
411
486
|
if (!res.ok) {
|
|
412
487
|
throw new ClientAttestationError(`token endpoint returned HTTP ${res.status}`, {
|
|
413
488
|
status: res.status,
|
|
414
|
-
body: res.body,
|
|
489
|
+
body: snippet(res.body),
|
|
415
490
|
});
|
|
416
491
|
}
|
|
417
492
|
const parsed = jsonOk(res, { allowEmpty: true });
|
|
418
493
|
if (!parsed.access_token) {
|
|
419
|
-
|
|
494
|
+
// No body: a 2xx token response may hold the token under another name.
|
|
495
|
+
throw new ClientAttestationError("token endpoint returned no access_token");
|
|
420
496
|
}
|
|
421
497
|
return parsed;
|
|
422
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
|
+
}
|
|
423
516
|
function isDpop(state) {
|
|
424
517
|
return state.tokenType.toLowerCase() === "dpop";
|
|
425
518
|
}
|
|
426
519
|
function scheme(state) {
|
|
427
520
|
return isDpop(state) ? "DPoP" : "Bearer";
|
|
428
521
|
}
|
|
429
|
-
/** `
|
|
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
|
+
*/
|
|
430
539
|
function originOf(url) {
|
|
431
540
|
try {
|
|
432
|
-
return
|
|
541
|
+
return htuParts(url)[0];
|
|
433
542
|
}
|
|
434
543
|
catch {
|
|
435
544
|
return url;
|
|
@@ -453,18 +562,64 @@ function replayable(body) {
|
|
|
453
562
|
}
|
|
454
563
|
/**
|
|
455
564
|
* 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"}`.
|
|
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.
|
|
457
568
|
*/
|
|
458
|
-
async function isNonceChallenge(res) {
|
|
569
|
+
async function isNonceChallenge(res, timeoutMs) {
|
|
459
570
|
if (res.status === 401) {
|
|
460
571
|
const challenge = res.headers.get("WWW-Authenticate") ?? "";
|
|
461
572
|
return /(^|,)\s*DPoP\b/i.test(challenge) && /error\s*=\s*"?use_dpop_nonce"?/i.test(challenge);
|
|
462
573
|
}
|
|
463
574
|
if (res.status === 400) {
|
|
464
|
-
|
|
575
|
+
const body = await peekBody(res, MAX_NONCE_CHALLENGE_BODY, timeoutMs);
|
|
576
|
+
return body !== undefined && jsonError(body) === USE_DPOP_NONCE;
|
|
465
577
|
}
|
|
466
578
|
return false;
|
|
467
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
|
+
}
|
|
468
623
|
/**
|
|
469
624
|
* Compact JSON with object keys sorted at every depth and non-ASCII escaped as `\uXXXX` - the same
|
|
470
625
|
* bytes as Python's `json.dumps(value, separators=(",", ":"), sort_keys=True)`.
|
|
@@ -483,6 +638,37 @@ function sortKeys(value) {
|
|
|
483
638
|
}
|
|
484
639
|
return value;
|
|
485
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
|
+
}
|
|
486
672
|
/** @internal */
|
|
487
673
|
export function normalizeBaseUrl(attesterUrl) {
|
|
488
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.0
|
|
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.0
|
|
12
|
+
export const VALIDATOR_VERSION = "1.2.0";
|