@udibo/oauth2 0.20.0 → 0.21.1

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.
@@ -319,10 +319,14 @@ export interface HonoBffOptions {
319
319
  * server-side store is needed. It cannot list sessions, so it does not
320
320
  * support {@link HonoBffOptions.backchannelLogout}, and sign-out cannot
321
321
  * invalidate a copied cookie: it stays valid until its `maxAgeMs`. Use a
322
- * shared {@link SessionStore} when you need revocation.
322
+ * shared {@link SessionStore} when you need revocation. Against an
323
+ * authorization server that rotates refresh tokens, a request still
324
+ * carrying the cookie from before a refresh replays the old token and
325
+ * trips reuse detection.
323
326
  * - A {@link SessionStore} over a database or cache every instance shares,
324
- * for server-side revocation and back-channel logout. Check it with
325
- * `runSessionStoreContractTests`.
327
+ * for server-side revocation and back-channel logout. Implement
328
+ * `RefreshLockStore` (`withRefreshLock`) on it so instances refresh a
329
+ * session one at a time, and check it with `runSessionStoreContractTests`.
326
330
  * - {@link MemorySessionStore} for a single process, tests and local
327
331
  * development. A session it holds is unknown to every other instance, so
328
332
  * behind a load balancer the user appears signed out.
@@ -58,8 +58,12 @@ import { sha256Hash } from "../../../server/utils/hash.js";
58
58
  import { OAUTH2_CONTEXT_KEY } from "../_common.js";
59
59
  import { DEFAULT_MAX_RETURN_TO_LENGTH, loginContinuation as computeLoginContinuation, safeReturnTo, } from "../../../utils/url.js";
60
60
  import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
61
- import { DEFAULT_SESSION_MAX_AGE_MS, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.js";
61
+ import { DEFAULT_SESSION_MAX_AGE_MS, sessionStoreMaxAgeMs, supportsBackchannelLogout, supportsRefreshLock, } from "./session-store.js";
62
62
  import { buildDownstreamHeaders, buildUpstreamHeaders, isInvalidTokenChallenge, resolveForwardHeaders, resolveProxyUrl, } from "./proxy.js";
63
+ function refreshedSince(seen, current) {
64
+ return (current.refreshToken !== seen.refreshToken ||
65
+ current.tokens.accessToken !== seen.tokens.accessToken);
66
+ }
63
67
  function isRevokedGrant(error) {
64
68
  if (!isOAuth2Error(error))
65
69
  return false;
@@ -1005,12 +1009,35 @@ export class HonoBff {
1005
1009
  const inFlight = this.#refreshes.get(entry.cookieValue);
1006
1010
  if (inFlight)
1007
1011
  return inFlight;
1008
- const pending = this.#exchangeRefresh(entry).finally(() => {
1012
+ const pending = this.#lockedRefresh(entry).finally(() => {
1009
1013
  this.#refreshes.delete(entry.cookieValue);
1010
1014
  });
1011
1015
  this.#refreshes.set(entry.cookieValue, pending);
1012
1016
  return pending;
1013
1017
  }
1018
+ async #lockedRefresh(entry) {
1019
+ const store = this.#store;
1020
+ if (!supportsRefreshLock(store))
1021
+ return await this.#exchangeRefresh(entry);
1022
+ const { cookieValue } = entry;
1023
+ try {
1024
+ return await store.withRefreshLock(cookieValue, async () => {
1025
+ const current = await store.read(cookieValue);
1026
+ if (!current)
1027
+ return { status: "revoked" };
1028
+ if (refreshedSince(entry.data, current)) {
1029
+ return {
1030
+ status: "refreshed",
1031
+ entry: { cookieValue, data: current },
1032
+ };
1033
+ }
1034
+ return await this.#exchangeRefresh({ cookieValue, data: current });
1035
+ });
1036
+ }
1037
+ catch {
1038
+ return { status: "unavailable" };
1039
+ }
1040
+ }
1014
1041
  async #exchangeRefresh(entry) {
1015
1042
  const { cookieValue, data } = entry;
1016
1043
  if (!data.refreshToken)
@@ -12,12 +12,13 @@
12
12
  * the memory pair serves a single process, tests and local development.
13
13
  *
14
14
  * Need a server-side store (Postgres, Redis, …)? Implement the
15
- * {@link SessionStore} interface and verify it with
16
- * `runSessionStoreContractTests` from `@udibo/oauth2/hono/bff/testing`.
15
+ * {@link SessionStore} interface, plus {@link RefreshLockStore} when several
16
+ * instances share it, and verify it with `runSessionStoreContractTests` from
17
+ * `@udibo/oauth2/hono/bff/testing`.
17
18
  *
18
19
  * @module
19
20
  */
20
21
  export { type BackchannelLogoutSubject, type BffCallbackError, HonoBff, type HonoBffBackchannelOptions, type HonoBffCookieOptions, type HonoBffCsrfOptions, type HonoBffOptions, type HonoBffPaths, type HonoBffResourceServer, } from "./bff.ts";
21
22
  export { type AuthRequestCookieOptions, type AuthRequestStorageFactory, EncryptedCookieAuthRequestStorage, encryptedCookieAuthRequestStorage, type EncryptedCookieAuthRequestStorageOptions, MemoryAuthRequestStorageFactory, } from "./auth-request-store.ts";
22
23
  export { DEFAULT_PROXY_FORWARD_HEADERS, type HonoBffProxyOptions, } from "./proxy.ts";
23
- export { type BackchannelLogoutStore, type BoundedSessionStore, DEFAULT_SESSION_MAX_AGE_MS, EncryptedCookieSessionStore, type EncryptedCookieSessionStoreOptions, MemorySessionStore, type SessionData, type SessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.ts";
24
+ export { type BackchannelLogoutStore, type BoundedSessionStore, DEFAULT_SESSION_MAX_AGE_MS, EncryptedCookieSessionStore, type EncryptedCookieSessionStoreOptions, MemorySessionStore, type RefreshLockStore, type SessionData, type SessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.ts";
@@ -12,8 +12,9 @@
12
12
  * the memory pair serves a single process, tests and local development.
13
13
  *
14
14
  * Need a server-side store (Postgres, Redis, …)? Implement the
15
- * {@link SessionStore} interface and verify it with
16
- * `runSessionStoreContractTests` from `@udibo/oauth2/hono/bff/testing`.
15
+ * {@link SessionStore} interface, plus {@link RefreshLockStore} when several
16
+ * instances share it, and verify it with `runSessionStoreContractTests` from
17
+ * `@udibo/oauth2/hono/bff/testing`.
17
18
  *
18
19
  * @module
19
20
  */
@@ -69,6 +69,54 @@ export interface BackchannelLogoutStore {
69
69
  }
70
70
  /** Whether a store implements the {@link BackchannelLogoutStore} capability. */
71
71
  export declare function supportsBackchannelLogout(store: SessionStore): store is SessionStore & BackchannelLogoutStore;
72
+ /**
73
+ * Optional {@link SessionStore} capability: one token refresh at a time per
74
+ * session, across every instance that shares the store. A store opts in by
75
+ * implementing this method (it is duck-typed at runtime).
76
+ *
77
+ * Without it, `HonoBff` shares a refresh only between requests on the same
78
+ * instance. Two instances that refresh one session at once both present its
79
+ * refresh token: against an authorization server that rotates refresh tokens,
80
+ * the loser's exchange fails, or trips reuse detection and revokes the whole
81
+ * rotation family, and the user is signed out. With it, the BFF re-reads the
82
+ * session while it holds the lock and uses a peer's fresh tokens instead of
83
+ * spending the refresh token again. Implement it on any store more than one
84
+ * instance shares. {@link EncryptedCookieSessionStore} cannot: its sessions
85
+ * live in each browser's cookie, with nothing on the server to lock.
86
+ */
87
+ export interface RefreshLockStore {
88
+ /**
89
+ * Runs `refresh` while no other `withRefreshLock` call for the same
90
+ * `cookieValue`, on any instance, is running, and settles with its result.
91
+ * Calls for other sessions must not wait on each other.
92
+ *
93
+ * - The lock is keyed by the cookie value, so it holds only for a store
94
+ * whose `update` returns the cookie value it was given. A store that
95
+ * issues a new value on update cannot implement it.
96
+ * - The lock must not block this store's own `read`, `update` or `destroy`:
97
+ * `refresh` reads, writes and can destroy the session it locks.
98
+ * - Release it when `refresh` settles, including when it throws.
99
+ * - The lock covers the whole refresh: the token request and the store's
100
+ * own reads and writes. With `DirectClient` a refresh can take about 30
101
+ * seconds when its discovery cache is cold, since discovery tries two
102
+ * well-known paths and every request has a 10-second deadline. If the
103
+ * lock expires when its holder dies, expire it only after that plus the
104
+ * store's own I/O, or renew the lease while it is held. A lock that
105
+ * expires mid-refresh lets a second instance spend the same token.
106
+ * - Reject when the lock cannot be acquired within a bound you choose.
107
+ * `HonoBff` then answers `502` `temporarily_unavailable` and keeps the
108
+ * session.
109
+ *
110
+ * A Postgres store can hold `pg_advisory_xact_lock` on a hash of the session
111
+ * key in a transaction around `refresh`, with a `lock_timeout`. A Redis
112
+ * store can take a key with `SET NX PX` and wait for it with a deadline. Key
113
+ * the lock by the same digest the store keys its records by, never the raw
114
+ * cookie value, and include the tenant in a tenant-scoped store.
115
+ */
116
+ withRefreshLock<T>(cookieValue: string, refresh: () => Promise<T>): Promise<T>;
117
+ }
118
+ /** Whether a store implements the {@link RefreshLockStore} capability. */
119
+ export declare function supportsRefreshLock(store: SessionStore): store is SessionStore & RefreshLockStore;
72
120
  /**
73
121
  * Default session lifetime — 14 days — shared by
74
122
  * {@link EncryptedCookieSessionStore} and `HonoBff`'s
@@ -129,7 +177,7 @@ export interface SessionStore {
129
177
  * restarts. Suitable for single-process development or tests; use a
130
178
  * persistent store in production.
131
179
  */
132
- export declare class MemorySessionStore implements SessionStore, BackchannelLogoutStore {
180
+ export declare class MemorySessionStore implements SessionStore, BackchannelLogoutStore, RefreshLockStore {
133
181
  #private;
134
182
  /** Stores the session under a fresh random id and returns it as the cookie value. */
135
183
  create(data: SessionData): Promise<string>;
@@ -139,6 +187,12 @@ export declare class MemorySessionStore implements SessionStore, BackchannelLogo
139
187
  update(cookieValue: string, data: SessionData): Promise<string>;
140
188
  /** Removes the session record for the given id. */
141
189
  destroy(cookieValue: string): Promise<void>;
190
+ /**
191
+ * Runs `refresh` once every earlier call for the same session has settled,
192
+ * so every `HonoBff` sharing this store object refreshes a session once.
193
+ * Calls for other sessions run alongside.
194
+ */
195
+ withRefreshLock<T>(cookieValue: string, refresh: () => Promise<T>): Promise<T>;
142
196
  /**
143
197
  * Destroys the sessions a validated logout_token targets: the specific `sid`
144
198
  * when given, otherwise every session for `sub`. Returns the number
@@ -30,6 +30,10 @@ export function supportsBackchannelLogout(store) {
30
30
  return (typeof store.destroyByLogout ===
31
31
  "function");
32
32
  }
33
+ /** Whether a store implements the {@link RefreshLockStore} capability. */
34
+ export function supportsRefreshLock(store) {
35
+ return (typeof store.withRefreshLock === "function");
36
+ }
33
37
  /**
34
38
  * Default session lifetime — 14 days — shared by
35
39
  * {@link EncryptedCookieSessionStore} and `HonoBff`'s
@@ -58,6 +62,7 @@ export function sessionStoreMaxAgeMs(store) {
58
62
  */
59
63
  export class MemorySessionStore {
60
64
  #sessions = new Map();
65
+ #refreshLocks = new Map();
61
66
  /** Stores the session under a fresh random id and returns it as the cookie value. */
62
67
  create(data) {
63
68
  const id = crypto.randomUUID();
@@ -81,6 +86,25 @@ export class MemorySessionStore {
81
86
  this.#sessions.delete(cookieValue);
82
87
  return Promise.resolve();
83
88
  }
89
+ /**
90
+ * Runs `refresh` once every earlier call for the same session has settled,
91
+ * so every `HonoBff` sharing this store object refreshes a session once.
92
+ * Calls for other sessions run alongside.
93
+ */
94
+ async withRefreshLock(cookieValue, refresh) {
95
+ const previous = this.#refreshLocks.get(cookieValue) ?? Promise.resolve();
96
+ const run = previous.then(refresh);
97
+ const released = run.then(() => undefined, () => undefined);
98
+ this.#refreshLocks.set(cookieValue, released);
99
+ try {
100
+ return await run;
101
+ }
102
+ finally {
103
+ if (this.#refreshLocks.get(cookieValue) === released) {
104
+ this.#refreshLocks.delete(cookieValue);
105
+ }
106
+ }
107
+ }
84
108
  /**
85
109
  * Destroys the sessions a validated logout_token targets: the specific `sid`
86
110
  * when given, otherwise every session for `sub`. Returns the number
@@ -10,7 +10,8 @@
10
10
  * The runner-style helper {@link runSessionStoreContractTests} is
11
11
  * available for verifying custom {@link SessionStore} implementations
12
12
  * (Postgres, Redis, DynamoDB, …) satisfy the create / read / update /
13
- * destroy contract.
13
+ * destroy contract, and the refresh lock when the store implements
14
+ * `RefreshLockStore`.
14
15
  *
15
16
  * @module
16
17
  */
@@ -171,5 +172,11 @@ export interface SessionStoreContractOptions {
171
172
  * module's top level. It does not check that a stateful store's `update`
172
173
  * rejects a missing or destroyed session, which {@link SessionStore.update}
173
174
  * requires — test that yourself.
175
+ *
176
+ * A store that implements `withRefreshLock` ({@link RefreshLockStore}) also
177
+ * runs the lock checks; they are skipped for a store without it. They use one
178
+ * store object, so they prove the lock's behavior, not that separate
179
+ * instances share it: point two store objects at one backend in a test of
180
+ * your own for that.
174
181
  */
175
182
  export declare function runSessionStoreContractTests(options: SessionStoreContractOptions): void;
@@ -10,13 +10,14 @@
10
10
  * The runner-style helper {@link runSessionStoreContractTests} is
11
11
  * available for verifying custom {@link SessionStore} implementations
12
12
  * (Postgres, Redis, DynamoDB, …) satisfy the create / read / update /
13
- * destroy contract.
13
+ * destroy contract, and the refresh lock when the store implements
14
+ * `RefreshLockStore`.
14
15
  *
15
16
  * @module
16
17
  */
17
18
  import { assert, beforeEach, describe, expect, it } from "vitest";
18
19
  import { delay } from "../../../utils/_delay.js";
19
- import { sessionStoreMaxAgeMs, } from "./session-store.js";
20
+ import { sessionStoreMaxAgeMs, supportsRefreshLock, } from "./session-store.js";
20
21
  /**
21
22
  * Seeds a session in `bff.sessionStore` and returns a `Cookie` header
22
23
  * value (`<name>=<value>`) ready to attach to subsequent
@@ -165,12 +166,36 @@ function parseCookieHeader(header, name) {
165
166
  throw new Error(`parseCookieHeader: cookie "${name}" not found in header "${header}"`);
166
167
  }
167
168
  const BOUND_MS = 1000;
169
+ const LOCK_WAIT_MS = 2000;
170
+ const LOCK_CONTENTION_MS = 100;
171
+ async function settlesWithin(promise, message) {
172
+ const timer = new AbortController();
173
+ const { signal } = timer;
174
+ const timedOut = Symbol("timed out");
175
+ try {
176
+ const result = await Promise.race([
177
+ promise,
178
+ delay(LOCK_WAIT_MS, { signal }).then(() => timedOut),
179
+ ]);
180
+ assert(result !== timedOut, message);
181
+ return result;
182
+ }
183
+ finally {
184
+ timer.abort();
185
+ }
186
+ }
168
187
  /**
169
188
  * Contract test suite for {@link SessionStore} implementations. Registers
170
189
  * `describe`/`it` blocks from Vitest, so call it at a test
171
190
  * module's top level. It does not check that a stateful store's `update`
172
191
  * rejects a missing or destroyed session, which {@link SessionStore.update}
173
192
  * requires — test that yourself.
193
+ *
194
+ * A store that implements `withRefreshLock` ({@link RefreshLockStore}) also
195
+ * runs the lock checks; they are skipped for a store without it. They use one
196
+ * store object, so they prove the lock's behavior, not that separate
197
+ * instances share it: point two store objects at one backend in a test of
198
+ * your own for that.
174
199
  */
175
200
  export function runSessionStoreContractTests(options) {
176
201
  const destroyClearsRead = options.destroyClearsRead ?? true;
@@ -244,6 +269,95 @@ export function runSessionStoreContractTests(options) {
244
269
  "session does. Supply makeBoundedStore so the contract can prove " +
245
270
  "the bound is real.").toStrictEqual(false);
246
271
  });
272
+ function lockStore(skip) {
273
+ skip(!supportsRefreshLock(store), "the store does not implement withRefreshLock");
274
+ return store;
275
+ }
276
+ it("withRefreshLock runs one refresh of a session at a time", async ({ skip, }) => {
277
+ const locking = lockStore(skip);
278
+ const value = await locking.create(sampleSession());
279
+ const firstStarted = Promise.withResolvers();
280
+ const releaseFirst = Promise.withResolvers();
281
+ const order = [];
282
+ const first = locking.withRefreshLock(value, async () => {
283
+ order.push("first started");
284
+ firstStarted.resolve();
285
+ await releaseFirst.promise;
286
+ order.push("first settled");
287
+ return "first";
288
+ });
289
+ await settlesWithin(firstStarted.promise, "withRefreshLock never ran a refresh on a session nobody else holds");
290
+ const second = locking.withRefreshLock(value, () => {
291
+ order.push("second started");
292
+ return Promise.resolve("second");
293
+ });
294
+ await delay(LOCK_CONTENTION_MS);
295
+ expect(order, "a second refresh of one session started while the first still held " +
296
+ "the lock, so both would spend the same refresh token").toStrictEqual(["first started"]);
297
+ releaseFirst.resolve();
298
+ expect(await settlesWithin(Promise.all([first, second]), "the second refresh never ran after the first released the lock")).toStrictEqual(["first", "second"]);
299
+ expect(order).toStrictEqual([
300
+ "first started",
301
+ "first settled",
302
+ "second started",
303
+ ]);
304
+ });
305
+ it("withRefreshLock does not hold up another session's refresh", async ({ skip, }) => {
306
+ const locking = lockStore(skip);
307
+ const held = await locking.create(sampleSession());
308
+ const other = await locking.create(sampleSession());
309
+ const releaseHeld = Promise.withResolvers();
310
+ const holding = locking.withRefreshLock(held, () => releaseHeld.promise);
311
+ expect(await settlesWithin(locking.withRefreshLock(other, () => Promise.resolve("other")), "a refresh of one session waited on another session's lock")).toBe("other");
312
+ releaseHeld.resolve();
313
+ await holding;
314
+ });
315
+ it("withRefreshLock lets the refresh read and update the session it locks", async ({ skip, }) => {
316
+ const locking = lockStore(skip);
317
+ const value = await locking.create(sampleSession());
318
+ const updated = {
319
+ ...sampleSession(),
320
+ tokens: { ...sampleSession().tokens, accessToken: "access-2" },
321
+ refreshToken: "refresh-2",
322
+ };
323
+ const fetched = await settlesWithin(locking.withRefreshLock(value, async () => {
324
+ const next = await locking.update(value, updated);
325
+ return await locking.read(next);
326
+ }), "read or update blocked inside withRefreshLock: HonoBff reads and " +
327
+ "writes the session while it holds the lock, so it would deadlock");
328
+ expect(fetched?.tokens.accessToken).toBe("access-2");
329
+ expect(fetched?.refreshToken).toBe("refresh-2");
330
+ });
331
+ it("withRefreshLock lets the refresh destroy the session it locks", async ({ skip, }) => {
332
+ const locking = lockStore(skip);
333
+ const value = await locking.create(sampleSession());
334
+ await settlesWithin(locking.withRefreshLock(value, () => locking.destroy(value)), "destroy blocked inside withRefreshLock: HonoBff destroys the " +
335
+ "session it locks when the authorization server refuses the " +
336
+ "refresh, so it would deadlock");
337
+ });
338
+ it("withRefreshLock runs a refresh that was waiting when the holder threw", async ({ skip, }) => {
339
+ const locking = lockStore(skip);
340
+ const value = await locking.create(sampleSession());
341
+ const failure = new Error("token endpoint unreachable");
342
+ const releaseFirst = Promise.withResolvers();
343
+ const first = locking.withRefreshLock(value, async () => {
344
+ await releaseFirst.promise;
345
+ throw failure;
346
+ });
347
+ const waiting = locking.withRefreshLock(value, () => Promise.resolve("waiting"));
348
+ releaseFirst.resolve();
349
+ await expect(first).rejects.toBe(failure);
350
+ expect(await settlesWithin(waiting.catch((error) => error), "a refresh queued behind one that threw never ran, so every " +
351
+ "request waiting on that session hangs"), "a refresh queued behind one that threw must run, not inherit its error").toBe("waiting");
352
+ });
353
+ it("withRefreshLock releases the lock when the refresh throws", async ({ skip, }) => {
354
+ const locking = lockStore(skip);
355
+ const value = await locking.create(sampleSession());
356
+ const failure = new Error("token endpoint unreachable");
357
+ await expect(locking.withRefreshLock(value, () => Promise.reject(failure))).rejects.toBe(failure);
358
+ expect(await settlesWithin(locking.withRefreshLock(value, () => Promise.resolve("next")), "a refresh that threw kept the lock, so the session can never " +
359
+ "refresh again")).toBe("next");
360
+ });
247
361
  if (makeBoundedStore) {
248
362
  it("reports the max age it was built with, so a BFF can outlive it", async () => {
249
363
  const bounded = await makeBoundedStore(BOUND_MS);
@@ -23,6 +23,7 @@ import { PasswordGrant } from "../../server/grants/password.js";
23
23
  import { RefreshTokenGrant } from "../../server/grants/refresh-token.js";
24
24
  import { StaticSigningKeyProvider, } from "../../server/signing-keys.js";
25
25
  import { generateCodeChallenge, generateCodeVerifier, generateState, } from "../../utils/pkce.js";
26
+ import { DEFAULT_MAX_BODY_BYTES, readBoundedBody } from "../../utils/_body.js";
26
27
  import { timingSafeEqualString } from "../../utils/crypto.js";
27
28
  import { safeReturnTo } from "../../utils/url.js";
28
29
  import { MemoryAuthorizationCodeService, MemoryClientService, MemoryTokenService, MemoryUserService, } from "../../testing/services.js";
@@ -218,8 +219,22 @@ function handleConsentFor(state, request) {
218
219
  }));
219
220
  };
220
221
  }
222
+ class BodyTooLargeError extends Error {
223
+ }
224
+ async function readBody(request) {
225
+ const bytes = await readBoundedBody(request, DEFAULT_MAX_BODY_BYTES);
226
+ if (!bytes) {
227
+ throw new BodyTooLargeError(`request body exceeds ${DEFAULT_MAX_BODY_BYTES} bytes`);
228
+ }
229
+ return bytes;
230
+ }
231
+ async function readFormBody(request) {
232
+ return await new Response(await readBody(request), {
233
+ headers: { "content-type": request.headers.get("content-type") ?? "" },
234
+ }).formData();
235
+ }
221
236
  async function handleLoginSubmission(state, config, request) {
222
- const form = await request.formData();
237
+ const form = await readFormBody(request);
223
238
  const returnTo = safeReturnTo(formValue(form, "return_to"));
224
239
  const quickSignIn = formValue(form, "as");
225
240
  let user;
@@ -252,7 +267,7 @@ async function handleConsentSubmission(state, request) {
252
267
  const user = await currentUser(state, request);
253
268
  if (!user)
254
269
  return redirect("/login");
255
- const form = await request.formData();
270
+ const form = await readFormBody(request);
256
271
  const returnTo = safeReturnTo(formValue(form, "return_to"));
257
272
  const target = new URL(returnTo, request.url);
258
273
  const clientId = target.searchParams.get("client_id");
@@ -294,7 +309,7 @@ function handleLogout(state, config, request) {
294
309
  });
295
310
  }
296
311
  async function readJsonBody(request) {
297
- const text = await request.text();
312
+ const text = new TextDecoder().decode(await readBody(request));
298
313
  if (text.trim().length === 0)
299
314
  return {};
300
315
  const parsed = JSON.parse(text);
@@ -522,7 +537,7 @@ export async function startDevIdentityProvider(options) {
522
537
  }
523
538
  }
524
539
  catch (error) {
525
- return jsonError(400, error instanceof Error ? error.message : String(error));
540
+ return jsonError(error instanceof BodyTooLargeError ? 413 : 400, error instanceof Error ? error.message : String(error));
526
541
  }
527
542
  };
528
543
  const server = serveNode({
@@ -24,10 +24,25 @@ export declare const ID_TOKEN_EXPIRY_LEEWAY_SECONDS = 120;
24
24
  * OAuth2 error carries a description the provider wrote.
25
25
  */
26
26
  export declare function describeError(error: unknown): string;
27
+ /**
28
+ * Response bytes a connector reads from a provider endpoint — token, profile,
29
+ * UserInfo, or key set — before refusing the response.
30
+ */
31
+ export declare const MAX_PROVIDER_RESPONSE_BYTES: number;
32
+ /**
33
+ * Reads `res`'s body as text, stopping and cancelling it past
34
+ * {@link MAX_PROVIDER_RESPONSE_BYTES}. `source` names the endpoint in the
35
+ * error message, e.g. `"the Acme profile endpoint"`.
36
+ *
37
+ * @throws {ExternalAuthError} `provider_error` when the body is larger than
38
+ * the cap or stops arriving.
39
+ */
40
+ export declare function readProviderText(res: Response, provider: string, source: string): Promise<string>;
27
41
  /**
28
42
  * Reads a failed response's body into a bounded, single-line `": <text>"`
29
- * suffix to append to an error message, or `""` when the body is empty or
30
- * unreadable. Consumes the body, so the caller must not read it again.
43
+ * suffix to append to an error message, or `""` when the body is empty,
44
+ * unreadable, or larger than {@link MAX_PROVIDER_RESPONSE_BYTES}. Consumes
45
+ * the body, so the caller must not read it again.
31
46
  */
32
47
  export declare function readErrorBody(res: Response): Promise<string>;
33
48
  /**
@@ -9,6 +9,7 @@
9
9
  * @module
10
10
  */
11
11
  import { isOAuth2Error } from "../../errors.js";
12
+ import { readBoundedBody } from "../../utils/_body.js";
12
13
  import { timingSafeEqualString } from "../../utils/crypto.js";
13
14
  import { PROVIDER_TEXT_MAX_LENGTH, sanitizeProviderText, } from "../../utils/text.js";
14
15
  import { ExternalAuthError } from "./errors.js";
@@ -36,14 +37,45 @@ export function describeError(error) {
36
37
  }
37
38
  return sanitizeProviderText(error instanceof Error ? error.message : String(error));
38
39
  }
40
+ /**
41
+ * Response bytes a connector reads from a provider endpoint — token, profile,
42
+ * UserInfo, or key set — before refusing the response.
43
+ */
44
+ export const MAX_PROVIDER_RESPONSE_BYTES = 64 * 1024;
45
+ /**
46
+ * Reads `res`'s body as text, stopping and cancelling it past
47
+ * {@link MAX_PROVIDER_RESPONSE_BYTES}. `source` names the endpoint in the
48
+ * error message, e.g. `"the Acme profile endpoint"`.
49
+ *
50
+ * @throws {ExternalAuthError} `provider_error` when the body is larger than
51
+ * the cap or stops arriving.
52
+ */
53
+ export async function readProviderText(res, provider, source) {
54
+ let bytes;
55
+ try {
56
+ bytes = await readBoundedBody(res, MAX_PROVIDER_RESPONSE_BYTES);
57
+ }
58
+ catch (error) {
59
+ throw new ExternalAuthError(provider, "provider_error", `${source} response could not be read (${describeError(error)}).`, { cause: error });
60
+ }
61
+ if (!bytes) {
62
+ throw new ExternalAuthError(provider, "provider_error", `${source} response exceeded ${MAX_PROVIDER_RESPONSE_BYTES} bytes — ` +
63
+ `refusing to parse a response this large.`);
64
+ }
65
+ return new TextDecoder().decode(bytes);
66
+ }
39
67
  /**
40
68
  * Reads a failed response's body into a bounded, single-line `": <text>"`
41
- * suffix to append to an error message, or `""` when the body is empty or
42
- * unreadable. Consumes the body, so the caller must not read it again.
69
+ * suffix to append to an error message, or `""` when the body is empty,
70
+ * unreadable, or larger than {@link MAX_PROVIDER_RESPONSE_BYTES}. Consumes
71
+ * the body, so the caller must not read it again.
43
72
  */
44
73
  export async function readErrorBody(res) {
45
74
  try {
46
- const snippet = sanitizeProviderText(await res.text());
75
+ const bytes = await readBoundedBody(res, MAX_PROVIDER_RESPONSE_BYTES);
76
+ if (!bytes)
77
+ return "";
78
+ const snippet = sanitizeProviderText(new TextDecoder().decode(bytes));
47
79
  return snippet ? `: ${snippet}` : "";
48
80
  }
49
81
  catch {
@@ -9,10 +9,10 @@
9
9
  * @module
10
10
  */
11
11
  import { packageDeadlineSignal } from "../../utils/_default-fetch.js";
12
- import { describeError, sanitizeProviderText } from "./_shared.js";
12
+ import { describeError, MAX_PROVIDER_RESPONSE_BYTES, sanitizeProviderText, } from "./_shared.js";
13
13
  import { ExternalAuthError } from "./errors.js";
14
14
  /** Response bytes accepted from a token endpoint before the exchange is refused. */
15
- export const MAX_TOKEN_RESPONSE_BYTES = 64 * 1024;
15
+ export const MAX_TOKEN_RESPONSE_BYTES = MAX_PROVIDER_RESPONSE_BYTES;
16
16
  /** Wall-clock budget for the exchange, covering the connect and the body read. */
17
17
  export const TOKEN_EXCHANGE_TIMEOUT_MS = 10_000;
18
18
  /**
@@ -18,7 +18,7 @@
18
18
  import { toArrayBuffer } from "../../utils/_buffer.js";
19
19
  import { base64urlDecode } from "../../utils/crypto.js";
20
20
  import { defaultFetch } from "../../utils/_default-fetch.js";
21
- import { assertIdTokenClaims, describeError } from "./_shared.js";
21
+ import { assertIdTokenClaims, describeError, readProviderText, } from "./_shared.js";
22
22
  import { runTokenExchange } from "./_token-exchange.js";
23
23
  import { createAppleClientSecretFactory, } from "./apple-client-secret.js";
24
24
  import { ExternalAuthError } from "./errors.js";
@@ -110,9 +110,10 @@ export function appleProvider(options) {
110
110
  await res.body?.cancel();
111
111
  throw new ExternalAuthError(id, "provider_error", `Apple's JWKS endpoint responded with HTTP ${res.status}.`);
112
112
  }
113
+ const text = await readProviderText(res, id, "Apple's JWKS endpoint");
113
114
  let doc;
114
115
  try {
115
- doc = (await res.json());
116
+ doc = JSON.parse(text);
116
117
  }
117
118
  catch (error) {
118
119
  throw new ExternalAuthError(id, "provider_error", `Apple's JWKS endpoint returned invalid JSON (${describeError(error)}).`, { cause: error });
@@ -5,7 +5,7 @@
5
5
  * @module
6
6
  */
7
7
  import { defaultFetch } from "../../utils/_default-fetch.js";
8
- import { describeError } from "./_shared.js";
8
+ import { describeError, readProviderText } from "./_shared.js";
9
9
  import { runTokenExchange } from "./_token-exchange.js";
10
10
  import { ExternalAuthError } from "./errors.js";
11
11
  const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
@@ -74,8 +74,9 @@ export function githubProvider(options) {
74
74
  await res.body?.cancel();
75
75
  return null;
76
76
  }
77
+ const text = await readProviderText(res, id, url);
77
78
  try {
78
- return await res.json();
79
+ return JSON.parse(text);
79
80
  }
80
81
  catch (error) {
81
82
  throw new ExternalAuthError(id, "provider_error", `${url} returned a response that was not valid JSON ` +
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { defaultFetch } from "../../utils/_default-fetch.js";
11
11
  import { generateCodeChallenge } from "../../utils/pkce.js";
12
- import { describeError, readErrorBody } from "./_shared.js";
12
+ import { describeError, readErrorBody, readProviderText } from "./_shared.js";
13
13
  import { runTokenExchange } from "./_token-exchange.js";
14
14
  import { ExternalAuthError } from "./errors.js";
15
15
  /**
@@ -85,8 +85,9 @@ export function oauth2Provider(options) {
85
85
  `access token was issued but lacks profile access — ensure the ` +
86
86
  `identity scope is requested.`);
87
87
  }
88
+ const text = await readProviderText(res, id, `the ${options.displayName} profile endpoint`);
88
89
  try {
89
- return (await res.json());
90
+ return JSON.parse(text);
90
91
  }
91
92
  catch (error) {
92
93
  throw new ExternalAuthError(id, "provider_error", `the ${options.displayName} profile endpoint returned a response ` +
@@ -9,7 +9,7 @@ import { MemoryAuthRequestStorage } from "../../client/storage.js";
9
9
  import { isOAuth2Error } from "../../errors.js";
10
10
  import { defaultFetch } from "../../utils/_default-fetch.js";
11
11
  import { generateCodeChallenge } from "../../utils/pkce.js";
12
- import { assertIdTokenClaims, describeError, stripTrailingSlash, } from "./_shared.js";
12
+ import { assertIdTokenClaims, describeError, readProviderText, stripTrailingSlash, } from "./_shared.js";
13
13
  import { ExternalAuthError } from "./errors.js";
14
14
  /**
15
15
  * Creates a connector for any spec-compliant OpenID Connect provider.
@@ -153,7 +153,7 @@ export function oidcProvider(options) {
153
153
  await res.body?.cancel();
154
154
  return null;
155
155
  }
156
- const info = (await res.json());
156
+ const info = JSON.parse(await readProviderText(res, id, "the UserInfo endpoint"));
157
157
  return info.sub === idTokenSub ? info : null;
158
158
  }
159
159
  catch {
@@ -32,6 +32,14 @@ export interface BreachedPasswordOptions {
32
32
  failOpen?: boolean;
33
33
  /** Abort the range request after this many ms. Defaults to 3000. */
34
34
  timeoutMs?: number;
35
+ /**
36
+ * Largest range response, in bytes, the check reads. Defaults to 1048576
37
+ * (1 MiB), about ten times a padded range. The read counts bytes as they
38
+ * arrive and cancels the response at the cap, whether or not it has a
39
+ * `Content-Length`; a larger response counts as the range API being
40
+ * unavailable, so `failOpen` decides the outcome.
41
+ */
42
+ maxResponseBytes?: number;
35
43
  /** Fetch implementation — inject for tests. Defaults to `globalThis.fetch`. */
36
44
  fetch?: typeof fetch;
37
45
  /** Range API base URL. Defaults to `https://api.pwnedpasswords.com/range/`. */
@@ -56,6 +64,8 @@ export declare function sha1Hex(input: string): Promise<string>;
56
64
  * Build a {@link PasswordPolicy} validator that rejects passwords found in the
57
65
  * HIBP breach corpus.
58
66
  *
67
+ * @throws {RangeError} If `maxResponseBytes` is not a positive integer.
68
+ *
59
69
  * @example
60
70
  * ```ts
61
71
  * const policy: PasswordPolicy = {
@@ -17,10 +17,12 @@
17
17
  *
18
18
  * @module
19
19
  */
20
+ import { assertMaxBodyBytes, readBoundedBody } from "../utils/_body.js";
20
21
  import { encodeHex } from "../utils/_encoding.js";
21
22
  import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
22
23
  import { dispatchIdentityEvent } from "./events.js";
23
24
  const DEFAULT_BASE_URL = "https://api.pwnedpasswords.com/range/";
25
+ const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
24
26
  const VALIDATOR_NAME = "breached_password";
25
27
  const BREACHED_MESSAGE = "This password has appeared in a data breach; choose a different one.";
26
28
  const UNAVAILABLE_MESSAGE = "The password couldn't be checked against known breaches; try again.";
@@ -33,6 +35,8 @@ export async function sha1Hex(input) {
33
35
  * Build a {@link PasswordPolicy} validator that rejects passwords found in the
34
36
  * HIBP breach corpus.
35
37
  *
38
+ * @throws {RangeError} If `maxResponseBytes` is not a positive integer.
39
+ *
36
40
  * @example
37
41
  * ```ts
38
42
  * const policy: PasswordPolicy = {
@@ -45,6 +49,8 @@ export function breachedPasswordValidator(options = {}) {
45
49
  const threshold = options.threshold ?? 1;
46
50
  const failOpen = options.failOpen ?? true;
47
51
  const timeoutMs = options.timeoutMs ?? 3000;
52
+ const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
53
+ assertMaxBodyBytes(maxResponseBytes, "maxResponseBytes");
48
54
  const fetchImpl = options.fetch ?? defaultFetch;
49
55
  const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
50
56
  const onEvent = options.onEvent;
@@ -61,7 +67,11 @@ export function breachedPasswordValidator(options = {}) {
61
67
  await response.body?.cancel();
62
68
  throw new Error(`range API responded ${response.status}`);
63
69
  }
64
- const body = await response.text();
70
+ const bytes = await readBoundedBody(response, maxResponseBytes);
71
+ if (!bytes) {
72
+ throw new Error(`range API response exceeded ${maxResponseBytes} bytes`);
73
+ }
74
+ const body = new TextDecoder().decode(bytes);
65
75
  for (const line of body.split("\n")) {
66
76
  const [lineSuffix, countText] = line.trim().split(":");
67
77
  if (lineSuffix === suffix && Number(countText) >= threshold) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Not part of the public API. The bounded JSON read the token readers apply
3
+ * to the responses of the authorization server they call.
4
+ *
5
+ * @module
6
+ */
7
+ /**
8
+ * Parses `response`'s body as JSON, reading at most `maxBytes`. `label`
9
+ * names the response in error messages.
10
+ *
11
+ * @throws {ServerError} When the body exceeds `maxBytes` (the read stops and
12
+ * the body is cancelled) or is not JSON.
13
+ * @throws {TemporarilyUnavailableError} When the body stops arriving before it
14
+ * is complete: the deadline fired or the connection dropped mid-body.
15
+ */
16
+ export declare function readResponseJson(response: Response, maxBytes: number, label: string): Promise<unknown>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Not part of the public API. The bounded JSON read the token readers apply
3
+ * to the responses of the authorization server they call.
4
+ *
5
+ * @module
6
+ */
7
+ import { ServerError, TemporarilyUnavailableError } from "../errors.js";
8
+ import { readBoundedBody } from "../utils/_body.js";
9
+ /**
10
+ * Parses `response`'s body as JSON, reading at most `maxBytes`. `label`
11
+ * names the response in error messages.
12
+ *
13
+ * @throws {ServerError} When the body exceeds `maxBytes` (the read stops and
14
+ * the body is cancelled) or is not JSON.
15
+ * @throws {TemporarilyUnavailableError} When the body stops arriving before it
16
+ * is complete: the deadline fired or the connection dropped mid-body.
17
+ */
18
+ export async function readResponseJson(response, maxBytes, label) {
19
+ let bytes;
20
+ try {
21
+ bytes = await readBoundedBody(response, maxBytes);
22
+ }
23
+ catch (cause) {
24
+ throw new TemporarilyUnavailableError(`${label} request failed`, { cause });
25
+ }
26
+ if (!bytes) {
27
+ throw new ServerError(`${label} response exceeded ${maxBytes} bytes`);
28
+ }
29
+ try {
30
+ return JSON.parse(new TextDecoder().decode(bytes));
31
+ }
32
+ catch (cause) {
33
+ throw new ServerError(`${label} response was not JSON`, { cause });
34
+ }
35
+ }
@@ -67,6 +67,13 @@ export interface IntrospectionTokenReaderOptions<Client extends ClientInterface,
67
67
  * {@linkcode fetch} that doesn't honour `AbortSignal`.
68
68
  */
69
69
  fetchTimeoutMs?: number;
70
+ /**
71
+ * Largest introspection response, in bytes, the reader accepts. Defaults to
72
+ * 65536 (64 KiB). The read counts bytes as they arrive and cancels the
73
+ * response at the cap, whether or not it has a `Content-Length`; a larger
74
+ * response throws {@linkcode ServerError}.
75
+ */
76
+ maxResponseBytes?: number;
70
77
  /**
71
78
  * `fetch` implementation used to call the introspection endpoint. Defaults
72
79
  * to the global `fetch`. Inject it to route through a custom client (mTLS,
@@ -127,10 +134,14 @@ export declare class IntrospectionTokenReader<Client extends ClientInterface, Us
127
134
  private getClient;
128
135
  private getUser?;
129
136
  private fetchTimeoutMs;
137
+ private maxResponseBytes;
130
138
  private customFetch?;
131
139
  /**
132
- * `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000, and
133
- * `fetch` to the global `fetch` when omitted from `options`.
140
+ * `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000,
141
+ * `maxResponseBytes` to 65536, and `fetch` to the global `fetch` when
142
+ * omitted from `options`.
143
+ *
144
+ * @throws {RangeError} If `maxResponseBytes` is not a positive integer.
134
145
  */
135
146
  constructor(options: IntrospectionTokenReaderOptions<Client, User, S>);
136
147
  /**
@@ -149,9 +160,10 @@ export declare class IntrospectionTokenReader<Client extends ClientInterface, Us
149
160
  * a genuinely invalid token (which is `undefined` → `invalid_token`), not
150
161
  * collapsed into it.
151
162
  * @throws {ServerError} If the endpoint returns any other non-2xx status
152
- * (e.g. the reader's own introspection credentials are rejected) or a body
153
- * that is not JSON — a misconfiguration, not a verdict on the caller's
154
- * token.
163
+ * (e.g. the reader's own introspection credentials are rejected), a body
164
+ * that is not JSON, or one larger than
165
+ * {@linkcode IntrospectionTokenReaderOptions.maxResponseBytes} — a
166
+ * misconfiguration, not a verdict on the caller's token.
155
167
  *
156
168
  * An active response is only accepted when its `token_type` names a bearer
157
169
  * token. RFC 7662 answers for refresh tokens too, and a refresh token's
@@ -12,6 +12,8 @@
12
12
  import { ServerError, TemporarilyUnavailableError } from "../errors.js";
13
13
  import { BasicScope } from "../models/scope.js";
14
14
  import { encodeBasicAuth } from "../utils/basic-auth.js";
15
+ import { assertMaxBodyBytes, DEFAULT_MAX_RESPONSE_BYTES, } from "../utils/_body.js";
16
+ import { readResponseJson } from "./_response-json.js";
15
17
  import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
16
18
  /**
17
19
  * Validates tokens by calling an OAuth2 introspection endpoint (RFC 7662).
@@ -62,10 +64,14 @@ export class IntrospectionTokenReader {
62
64
  getClient;
63
65
  getUser;
64
66
  fetchTimeoutMs;
67
+ maxResponseBytes;
65
68
  customFetch;
66
69
  /**
67
- * `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000, and
68
- * `fetch` to the global `fetch` when omitted from `options`.
70
+ * `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000,
71
+ * `maxResponseBytes` to 65536, and `fetch` to the global `fetch` when
72
+ * omitted from `options`.
73
+ *
74
+ * @throws {RangeError} If `maxResponseBytes` is not a positive integer.
69
75
  */
70
76
  constructor(options) {
71
77
  this.introspectionEndpoint = options.introspectionEndpoint;
@@ -76,6 +82,9 @@ export class IntrospectionTokenReader {
76
82
  this.getClient = options.getClient;
77
83
  this.getUser = options.getUser;
78
84
  this.fetchTimeoutMs = options.fetchTimeoutMs ?? 5_000;
85
+ this.maxResponseBytes =
86
+ options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
87
+ assertMaxBodyBytes(this.maxResponseBytes, "maxResponseBytes");
79
88
  this.customFetch = options.fetch;
80
89
  }
81
90
  /**
@@ -94,9 +103,10 @@ export class IntrospectionTokenReader {
94
103
  * a genuinely invalid token (which is `undefined` → `invalid_token`), not
95
104
  * collapsed into it.
96
105
  * @throws {ServerError} If the endpoint returns any other non-2xx status
97
- * (e.g. the reader's own introspection credentials are rejected) or a body
98
- * that is not JSON — a misconfiguration, not a verdict on the caller's
99
- * token.
106
+ * (e.g. the reader's own introspection credentials are rejected), a body
107
+ * that is not JSON, or one larger than
108
+ * {@linkcode IntrospectionTokenReaderOptions.maxResponseBytes} — a
109
+ * misconfiguration, not a verdict on the caller's token.
100
110
  *
101
111
  * An active response is only accepted when its `token_type` names a bearer
102
112
  * token. RFC 7662 answers for refresh tokens too, and a refresh token's
@@ -153,16 +163,7 @@ export class IntrospectionTokenReader {
153
163
  ? new TemporarilyUnavailableError(`token introspection failed (HTTP ${response.status})`)
154
164
  : new ServerError(`token introspection failed (HTTP ${response.status})`);
155
165
  }
156
- try {
157
- return (await response.json());
158
- }
159
- catch (cause) {
160
- throw controller.signal.aborted
161
- ? new TemporarilyUnavailableError("token introspection request failed", { cause })
162
- : new ServerError("token introspection response was not JSON", {
163
- cause,
164
- });
165
- }
166
+ return (await readResponseJson(response, this.maxResponseBytes, "token introspection"));
166
167
  }
167
168
  finally {
168
169
  clearTimeout(deadline);
@@ -146,6 +146,14 @@ export interface JwksTokenReaderOptions<Client extends ClientInterface, User, S
146
146
  * {@linkcode fetch} that doesn't honour `AbortSignal`.
147
147
  */
148
148
  fetchTimeoutMs?: number;
149
+ /**
150
+ * Largest discovery document or JWKS, in bytes, the reader accepts.
151
+ * Defaults to 262144 (256 KiB). The read counts bytes as they arrive and
152
+ * cancels the response at the cap, whether or not it has a
153
+ * `Content-Length`; a larger response fails the refresh with a
154
+ * {@linkcode ServerError}, as a malformed one does.
155
+ */
156
+ maxResponseBytes?: number;
149
157
  /** Optional scope constructor for parsing scope strings. Defaults to BasicScope. */
150
158
  Scope?: ScopeConstructor<S>;
151
159
  /**
@@ -233,14 +241,15 @@ export declare class JwksTokenReader<Client extends ClientInterface, User, S ext
233
241
  * Defaults: every Web Crypto asymmetric algorithm accepted, `at+jwt`
234
242
  * required as `typ`, `client_id` as the required client-id claim, 30
235
243
  * seconds of clock skew, a 10-minute cache with a 30-second minimum
236
- * between fetches, a 5-second request deadline, {@linkcode BasicScope},
237
- * and the global `fetch`.
244
+ * between fetches, a 5-second request deadline, a 256 KiB response cap,
245
+ * {@linkcode BasicScope}, and the global `fetch`.
238
246
  *
239
247
  * @throws {TypeError} If `issuer` is not an absolute URL, `audience` is
240
248
  * empty, or `algorithms` names an algorithm this package cannot verify
241
249
  * (a silently ignored entry would read as a policy that isn't enforced).
242
250
  * @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
243
- * overflows when converted to milliseconds.
251
+ * overflows when converted to milliseconds, or `maxResponseBytes` is not
252
+ * a positive integer.
244
253
  */
245
254
  constructor(options: JwksTokenReaderOptions<Client, User, S>);
246
255
  /**
@@ -263,8 +272,10 @@ export declare class JwksTokenReader<Client extends ClientInterface, User, S ext
263
272
  * invalid token, not collapsed into it. Once keys are cached, a failed
264
273
  * refresh is absorbed and the cached keys keep serving.
265
274
  * @throws {ServerError} If, with no keys cached, the JWKS endpoint returns
266
- * a 4xx or a body that is not a JWKS, or discovery reports no `jwks_uri`
267
- * — a misconfiguration, not a verdict on the caller's token.
275
+ * a 4xx or a body that is not a JWKS, either document exceeds
276
+ * {@linkcode JwksTokenReaderOptions.maxResponseBytes}, or discovery
277
+ * reports no `jwks_uri` — a misconfiguration, not a verdict on the
278
+ * caller's token.
268
279
  */
269
280
  getToken(accessToken: string): Promise<Token<Client, User, S> | undefined>;
270
281
  }
@@ -14,10 +14,12 @@
14
14
  */
15
15
  import { ServerError, TemporarilyUnavailableError } from "../errors.js";
16
16
  import { BasicScope } from "../models/scope.js";
17
+ import { assertMaxBodyBytes, DEFAULT_MAX_KEY_SET_BYTES, } from "../utils/_body.js";
17
18
  import { toArrayBuffer } from "../utils/_buffer.js";
18
19
  import { base64urlDecode } from "../utils/crypto.js";
19
20
  import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
20
21
  import { assertClockSkewSeconds } from "./_clock-skew.js";
22
+ import { readResponseJson } from "./_response-json.js";
21
23
  const ALGORITHMS = {
22
24
  ES256: {
23
25
  kty: "EC",
@@ -210,6 +212,7 @@ export class JwksTokenReader {
210
212
  #cacheMaxAgeMs;
211
213
  #minFetchIntervalMs;
212
214
  #fetchTimeoutMs;
215
+ #maxResponseBytes;
213
216
  #metadataUrls;
214
217
  #Scope;
215
218
  #getClient;
@@ -224,14 +227,15 @@ export class JwksTokenReader {
224
227
  * Defaults: every Web Crypto asymmetric algorithm accepted, `at+jwt`
225
228
  * required as `typ`, `client_id` as the required client-id claim, 30
226
229
  * seconds of clock skew, a 10-minute cache with a 30-second minimum
227
- * between fetches, a 5-second request deadline, {@linkcode BasicScope},
228
- * and the global `fetch`.
230
+ * between fetches, a 5-second request deadline, a 256 KiB response cap,
231
+ * {@linkcode BasicScope}, and the global `fetch`.
229
232
  *
230
233
  * @throws {TypeError} If `issuer` is not an absolute URL, `audience` is
231
234
  * empty, or `algorithms` names an algorithm this package cannot verify
232
235
  * (a silently ignored entry would read as a policy that isn't enforced).
233
236
  * @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
234
- * overflows when converted to milliseconds.
237
+ * overflows when converted to milliseconds, or `maxResponseBytes` is not
238
+ * a positive integer.
235
239
  */
236
240
  constructor(options) {
237
241
  this.#issuer = options.issuer;
@@ -255,6 +259,9 @@ export class JwksTokenReader {
255
259
  this.#cacheMaxAgeMs = options.cacheMaxAgeMs ?? 600_000;
256
260
  this.#minFetchIntervalMs = options.minFetchIntervalMs ?? 30_000;
257
261
  this.#fetchTimeoutMs = options.fetchTimeoutMs ?? 5_000;
262
+ this.#maxResponseBytes =
263
+ options.maxResponseBytes ?? DEFAULT_MAX_KEY_SET_BYTES;
264
+ assertMaxBodyBytes(this.#maxResponseBytes, "maxResponseBytes");
258
265
  this.#Scope =
259
266
  options.Scope ?? BasicScope;
260
267
  this.#getClient = options.getClient;
@@ -282,8 +289,10 @@ export class JwksTokenReader {
282
289
  * invalid token, not collapsed into it. Once keys are cached, a failed
283
290
  * refresh is absorbed and the cached keys keep serving.
284
291
  * @throws {ServerError} If, with no keys cached, the JWKS endpoint returns
285
- * a 4xx or a body that is not a JWKS, or discovery reports no `jwks_uri`
286
- * — a misconfiguration, not a verdict on the caller's token.
292
+ * a 4xx or a body that is not a JWKS, either document exceeds
293
+ * {@linkcode JwksTokenReaderOptions.maxResponseBytes}, or discovery
294
+ * reports no `jwks_uri` — a misconfiguration, not a verdict on the
295
+ * caller's token.
287
296
  */
288
297
  async getToken(accessToken) {
289
298
  const parsed = parseJws(accessToken);
@@ -499,12 +508,7 @@ export class JwksTokenReader {
499
508
  ? new TemporarilyUnavailableError(`${label} request failed (HTTP ${response.status})`)
500
509
  : new ServerError(`${label} request failed (HTTP ${response.status})`);
501
510
  }
502
- try {
503
- return await response.json();
504
- }
505
- catch (cause) {
506
- throw new ServerError(`${label} response was not JSON`, { cause });
507
- }
511
+ return await readResponseJson(response, this.#maxResponseBytes, label);
508
512
  }
509
513
  finally {
510
514
  clearTimeout(deadline);
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Internal bounded request-body reader for the endpoints that parse a body
3
- * before they have authenticated the caller.
2
+ * Internal bounded body reader for the request bodies the endpoints parse
3
+ * before they have authenticated the caller, and for the responses the
4
+ * package reads from the servers it calls.
4
5
  *
5
6
  * @module
6
7
  */
@@ -17,20 +18,30 @@ export declare const DEFAULT_MAX_BODY_BYTES: number;
17
18
  * block, so any token a client could send as `Authorization: Bearer` fits.
18
19
  */
19
20
  export declare const DEFAULT_MAX_TOKEN_BODY_BYTES: number;
21
+ /**
22
+ * The default cap on an introspection response: 64 KiB, the same cap the
23
+ * clients apply to every authorization-server response.
24
+ */
25
+ export declare const DEFAULT_MAX_RESPONSE_BYTES: number;
26
+ /**
27
+ * The default cap on a discovery document or JWKS: 256 KiB, room for dozens
28
+ * of RSA keys that each carry an X.509 certificate chain.
29
+ */
30
+ export declare const DEFAULT_MAX_KEY_SET_BYTES: number;
20
31
  /**
21
32
  * Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
22
33
  * safe integer.
23
34
  */
24
35
  export declare function assertMaxBodyBytes(maxBodyBytes: number, option: string): void;
25
36
  /**
26
- * Reads `request`'s body, counting bytes as they arrive, and returns `null`
27
- * once it exceeds `maxBytes` — cancelling the stream rather than draining it.
28
- * A `Content-Length` over the cap is refused without reading; one under it is
29
- * not trusted. Consumes the body unless `keepReadable` is set, which reads a
30
- * clone and leaves the original readable (a refusal cancels both). Rejects if
31
- * the stream errors or was already read.
37
+ * Reads `request`'s body — or a response's — counting bytes as they arrive,
38
+ * and returns `null` once it exceeds `maxBytes`, cancelling the stream rather
39
+ * than draining it. A `Content-Length` over the cap is refused without
40
+ * reading; one under it is not trusted. Consumes the body unless
41
+ * `keepReadable` is set, which reads a clone and leaves the original readable
42
+ * (a refusal cancels both). Rejects if the stream errors or was already read.
32
43
  */
33
- export declare function readBoundedBody(request: Request, maxBytes: number, options?: {
44
+ export declare function readBoundedBody(request: Request | Response, maxBytes: number, options?: {
34
45
  keepReadable?: boolean;
35
46
  }): Promise<Uint8Array<ArrayBuffer> | null>;
36
47
  /**
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Internal bounded request-body reader for the endpoints that parse a body
3
- * before they have authenticated the caller.
2
+ * Internal bounded body reader for the request bodies the endpoints parse
3
+ * before they have authenticated the caller, and for the responses the
4
+ * package reads from the servers it calls.
4
5
  *
5
6
  * @module
6
7
  */
@@ -17,6 +18,16 @@ export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
17
18
  * block, so any token a client could send as `Authorization: Bearer` fits.
18
19
  */
19
20
  export const DEFAULT_MAX_TOKEN_BODY_BYTES = 16 * 1024;
21
+ /**
22
+ * The default cap on an introspection response: 64 KiB, the same cap the
23
+ * clients apply to every authorization-server response.
24
+ */
25
+ export const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024;
26
+ /**
27
+ * The default cap on a discovery document or JWKS: 256 KiB, room for dozens
28
+ * of RSA keys that each carry an X.509 certificate chain.
29
+ */
30
+ export const DEFAULT_MAX_KEY_SET_BYTES = 256 * 1024;
20
31
  /**
21
32
  * Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
22
33
  * safe integer.
@@ -27,12 +38,12 @@ export function assertMaxBodyBytes(maxBodyBytes, option) {
27
38
  }
28
39
  }
29
40
  /**
30
- * Reads `request`'s body, counting bytes as they arrive, and returns `null`
31
- * once it exceeds `maxBytes` — cancelling the stream rather than draining it.
32
- * A `Content-Length` over the cap is refused without reading; one under it is
33
- * not trusted. Consumes the body unless `keepReadable` is set, which reads a
34
- * clone and leaves the original readable (a refusal cancels both). Rejects if
35
- * the stream errors or was already read.
41
+ * Reads `request`'s body — or a response's — counting bytes as they arrive,
42
+ * and returns `null` once it exceeds `maxBytes`, cancelling the stream rather
43
+ * than draining it. A `Content-Length` over the cap is refused without
44
+ * reading; one under it is not trusted. Consumes the body unless
45
+ * `keepReadable` is set, which reads a clone and leaves the original readable
46
+ * (a refusal cancels both). Rejects if the stream errors or was already read.
36
47
  */
37
48
  export async function readBoundedBody(request, maxBytes, options = {}) {
38
49
  if (!request.body)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
5
5
  "keywords": [
6
6
  "authorization-server",