@udibo/oauth2 0.19.0 → 0.21.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.
@@ -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,18 @@
1
+ /**
2
+ * Character-set rules for `WWW-Authenticate` challenge parameters (RFC 7235
3
+ * §2.2, RFC 9110 §5.6.4, RFC 6750 §3).
4
+ *
5
+ * @module
6
+ */
7
+ /** Throws `TypeError` unless `realm` is printable ASCII (U+0020–U+007E), the only text every HTTP stack carries in a header unaltered. */
8
+ export declare function assertRealm(realm: string): void;
9
+ /** The `realm` parameter as a quoted-string; `realm` must already satisfy {@linkcode assertRealm}. */
10
+ export declare function quoteRealm(realm: string): string;
11
+ /**
12
+ * Maps text into the RFC 6750 §3 `error_description` set
13
+ * (`%x20-21 / %x23-5B / %x5D-7E`): control characters are dropped, `"`
14
+ * becomes `'`, and every other code point outside the set becomes `?`.
15
+ */
16
+ export declare function toChallengeDescription(text: string): string;
17
+ /** `scope` with its space-separated tokens rejoined by single spaces, or `undefined` unless every token is an RFC 6750 §3 scope-token. */
18
+ export declare function toChallengeScope(scope: string): string | undefined;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Character-set rules for `WWW-Authenticate` challenge parameters (RFC 7235
3
+ * §2.2, RFC 9110 §5.6.4, RFC 6750 §3).
4
+ *
5
+ * @module
6
+ */
7
+ import { NQCHAR } from "../models/scope.js";
8
+ const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
9
+ // oxlint-disable-next-line no-control-regex
10
+ const CONTROL_CHARACTERS = /[\x00-\x1f\x7f]/g;
11
+ const OUTSIDE_DESCRIPTION_SET = /[^\x20\x21\x23-\x5b\x5d-\x7e]/gu;
12
+ const SCOPE_TOKEN_ONLY = new RegExp(`^${NQCHAR.source}+$`);
13
+ /** Throws `TypeError` unless `realm` is printable ASCII (U+0020–U+007E), the only text every HTTP stack carries in a header unaltered. */
14
+ export function assertRealm(realm) {
15
+ if (typeof realm !== "string" || !PRINTABLE_ASCII.test(realm)) {
16
+ throw new TypeError(`realm must contain only printable ASCII characters (U+0020 to U+007E), got ${JSON.stringify(realm)}`);
17
+ }
18
+ }
19
+ /** The `realm` parameter as a quoted-string; `realm` must already satisfy {@linkcode assertRealm}. */
20
+ export function quoteRealm(realm) {
21
+ return `"${realm.replace(/["\\]/g, "\\$&")}"`;
22
+ }
23
+ /**
24
+ * Maps text into the RFC 6750 §3 `error_description` set
25
+ * (`%x20-21 / %x23-5B / %x5D-7E`): control characters are dropped, `"`
26
+ * becomes `'`, and every other code point outside the set becomes `?`.
27
+ */
28
+ export function toChallengeDescription(text) {
29
+ return text
30
+ .replace(CONTROL_CHARACTERS, "")
31
+ .replaceAll('"', "'")
32
+ .replace(OUTSIDE_DESCRIPTION_SET, "?");
33
+ }
34
+ /** `scope` with its space-separated tokens rejoined by single spaces, or `undefined` unless every token is an RFC 6750 §3 scope-token. */
35
+ export function toChallengeScope(scope) {
36
+ const tokens = scope.split(" ").filter((token) => token !== "");
37
+ return tokens.length > 0 &&
38
+ tokens.every((token) => SCOPE_TOKEN_ONLY.test(token))
39
+ ? tokens.join(" ")
40
+ : undefined;
41
+ }
@@ -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
+ }
@@ -446,6 +446,8 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
446
446
  * @throws {Error} If `requireOidc` is set without `signingKeys`.
447
447
  * @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
448
448
  * positive integer.
449
+ * @throws {TypeError} If `realm` contains a character outside printable
450
+ * ASCII.
449
451
  */
450
452
  constructor(options: AuthorizationServerOptions<Client, User, S>);
451
453
  /**
@@ -29,6 +29,7 @@ import { ResourceServer, } from "./resource-server.js";
29
29
  import { defaultSubjectOf, signJwt, verifyJwt, } from "./signing-keys.js";
30
30
  import { validateCodeChallenge } from "../utils/pkce.js";
31
31
  import { oauth2ErrorResponse } from "./_error-response.js";
32
+ import { quoteRealm } from "./_challenge.js";
32
33
  import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../utils/_body.js";
33
34
  const ENDPOINTS = {
34
35
  authorization: {
@@ -200,6 +201,8 @@ export class AuthorizationServer extends ResourceServer {
200
201
  * @throws {Error} If `requireOidc` is set without `signingKeys`.
201
202
  * @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
202
203
  * positive integer.
204
+ * @throws {TypeError} If `realm` contains a character outside printable
205
+ * ASCII.
203
206
  */
204
207
  constructor(options) {
205
208
  super({
@@ -336,7 +339,7 @@ export class AuthorizationServer extends ResourceServer {
336
339
  return (error) => {
337
340
  this.addCacheHeaders(error);
338
341
  if (error.status === 401 && request.headers.get("authorization")) {
339
- error.headers.set("WWW-Authenticate", `Basic realm="${this.realm}"`);
342
+ error.headers.set("WWW-Authenticate", `Basic realm=${quoteRealm(this.realm)}`);
340
343
  }
341
344
  };
342
345
  }
@@ -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);
@@ -37,7 +37,14 @@ export interface ResourceServerOptions<Client extends ClientInterface, User, S e
37
37
  resolve: (request: Request) => ResourceServerContext<Client, User, S> | Promise<ResourceServerContext<Client, User, S>>;
38
38
  /** Scope constructor used to parse and compare scopes. Defaults to {@linkcode BasicScope}. */
39
39
  Scope?: ScopeConstructor<S>;
40
- /** Protection space named in `WWW-Authenticate` challenges. Defaults to `"Service"`. */
40
+ /**
41
+ * Protection space named in `WWW-Authenticate` challenges. Defaults to
42
+ * `"Service"`. Must be printable ASCII (U+0020 to U+007E); a product name
43
+ * with other characters needs an ASCII form here.
44
+ *
45
+ * @throws {TypeError} From the constructor when it contains any other
46
+ * character.
47
+ */
41
48
  realm?: string;
42
49
  /**
43
50
  * Leeway in seconds applied to the access token's expiry, to absorb clock
@@ -170,14 +177,22 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
170
177
  #private;
171
178
  /** Scope constructor used to parse and compare scopes. */
172
179
  Scope: ScopeConstructor<S>;
173
- /** Protection space named in `WWW-Authenticate` challenges. */
174
- realm: string;
175
180
  /** Body format of protected-resource refusals: OAuth2 or RFC 9457 problem details. See {@linkcode ResourceServerOptions.errorFormat}. */
176
181
  errorFormat: "oauth2" | "problem-details";
177
182
  /** When true, handler boundaries rethrow {@linkcode OAuth2Error} instead of returning a response. */
178
183
  throwOnError: boolean;
179
184
  /** Resolves the per-request {@link ResourceServerContext}. */
180
185
  protected resolveContext: (request: Request) => ResourceServerContext<Client, User, S> | Promise<ResourceServerContext<Client, User, S>>;
186
+ /** Protection space named in `WWW-Authenticate` challenges. */
187
+ get realm(): string;
188
+ /**
189
+ * Sets the protection space, preserving the previous value if validation
190
+ * fails.
191
+ *
192
+ * @throws {TypeError} If the value contains a character outside printable
193
+ * ASCII (U+0020 to U+007E).
194
+ */
195
+ set realm(value: string);
181
196
  /**
182
197
  * Leeway in seconds applied to the access token's expiry. Defaults to `0`.
183
198
  * May be reassigned to any non-negative finite number with a finite
@@ -197,6 +212,8 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
197
212
  * @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
198
213
  * overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
199
214
  * not a positive integer.
215
+ * @throws {TypeError} If `realm` contains a character outside printable
216
+ * ASCII.
200
217
  */
201
218
  constructor(options: ResourceServerOptions<Client, User, S>);
202
219
  /**
@@ -217,6 +234,14 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
217
234
  * {@linkcode InsufficientScopeError} is the one that tells it "your token is
218
235
  * fine, it just lacks scope" rather than sending it off to refresh.
219
236
  *
237
+ * The result is always printable ASCII, so it never makes `Headers` throw.
238
+ * `error_description` is the same text the oauth2 response body uses (the
239
+ * `error_description` extension, else the error's exposed message, so an
240
+ * unexposed message stays out of both), narrowed to the RFC 6750 character
241
+ * set: control characters are dropped, `"` becomes `'`, and any other
242
+ * character outside the set becomes `?`. `scope` is omitted entirely when
243
+ * any of its tokens falls outside the RFC 6750 set.
244
+ *
220
245
  * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
221
246
  */
222
247
  buildWwwAuthenticate(error?: OAuth2Error, requiredScope?: string): string;
@@ -11,6 +11,7 @@ import { AccessDeniedError, InsufficientPermissionsError, InsufficientScopeError
11
11
  import { authorizationFromClaims, } from "../models/authorization.js";
12
12
  import { copyErrorHeaders, oauth2ErrorResponse } from "./_error-response.js";
13
13
  import { assertClockSkewSeconds } from "./_clock-skew.js";
14
+ import { assertRealm, quoteRealm, toChallengeDescription, toChallengeScope, } from "./_challenge.js";
14
15
  import { assertMaxBodyBytes, DEFAULT_MAX_TOKEN_BODY_BYTES, peekBoundedBody, } from "../utils/_body.js";
15
16
  /** Bearer token regex pattern. */
16
17
  export const BEARER_TOKEN = /^ *(?:[Bb][Ee][Aa][Rr][Ee][Rr]) +([\w-.~+/]+=*) *$/;
@@ -73,8 +74,6 @@ const CHALLENGE_ERROR_CODES = new Set([
73
74
  export class ResourceServer {
74
75
  /** Scope constructor used to parse and compare scopes. */
75
76
  Scope;
76
- /** Protection space named in `WWW-Authenticate` challenges. */
77
- realm;
78
77
  /** Body format of protected-resource refusals: OAuth2 or RFC 9457 problem details. See {@linkcode ResourceServerOptions.errorFormat}. */
79
78
  errorFormat;
80
79
  /** When true, handler boundaries rethrow {@linkcode OAuth2Error} instead of returning a response. */
@@ -83,6 +82,22 @@ export class ResourceServer {
83
82
  resolveContext;
84
83
  #clockSkewSeconds = 0;
85
84
  #maxTokenBodyBytes;
85
+ #realm = "Service";
86
+ /** Protection space named in `WWW-Authenticate` challenges. */
87
+ get realm() {
88
+ return this.#realm;
89
+ }
90
+ /**
91
+ * Sets the protection space, preserving the previous value if validation
92
+ * fails.
93
+ *
94
+ * @throws {TypeError} If the value contains a character outside printable
95
+ * ASCII (U+0020 to U+007E).
96
+ */
97
+ set realm(value) {
98
+ assertRealm(value);
99
+ this.#realm = value;
100
+ }
86
101
  /**
87
102
  * Leeway in seconds applied to the access token's expiry. Defaults to `0`.
88
103
  * May be reassigned to any non-negative finite number with a finite
@@ -107,6 +122,8 @@ export class ResourceServer {
107
122
  * @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
108
123
  * overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
109
124
  * not a positive integer.
125
+ * @throws {TypeError} If `realm` contains a character outside printable
126
+ * ASCII.
110
127
  */
111
128
  constructor(options) {
112
129
  this.Scope =
@@ -138,31 +155,33 @@ export class ResourceServer {
138
155
  * {@linkcode InsufficientScopeError} is the one that tells it "your token is
139
156
  * fine, it just lacks scope" rather than sending it off to refresh.
140
157
  *
158
+ * The result is always printable ASCII, so it never makes `Headers` throw.
159
+ * `error_description` is the same text the oauth2 response body uses (the
160
+ * `error_description` extension, else the error's exposed message, so an
161
+ * unexposed message stays out of both), narrowed to the RFC 6750 character
162
+ * set: control characters are dropped, `"` becomes `'`, and any other
163
+ * character outside the set becomes `?`. `scope` is omitted entirely when
164
+ * any of its tokens falls outside the RFC 6750 set.
165
+ *
141
166
  * @see https://datatracker.ietf.org/doc/html/rfc6750#section-3
142
167
  */
143
168
  buildWwwAuthenticate(error, requiredScope) {
144
- const quote = (value) => value
145
- // oxlint-disable-next-line no-control-regex
146
- .replace(/[\x00-\x1f\x7f]/g, "")
147
- .replace(/\\/g, "\\\\")
148
- .replace(/"/g, '\\"');
149
- const parts = [`realm="${quote(this.realm)}"`];
169
+ const parts = [`realm=${quoteRealm(this.realm)}`];
150
170
  const code = error?.extensions.error;
151
171
  if (error && code && CHALLENGE_ERROR_CODES.has(code)) {
152
- parts.push(`error="${quote(code)}"`);
153
- if (error.message) {
154
- parts.push(`error_description="${quote(error.message)}"`);
172
+ parts.push(`error="${code}"`);
173
+ const description = toChallengeDescription(error.extensions.error_description ?? error.exposedMessage);
174
+ if (description) {
175
+ parts.push(`error_description="${description}"`);
155
176
  }
156
177
  }
157
- if (requiredScope) {
158
- parts.push(`scope="${quote(requiredScope)}"`);
159
- }
160
- else if (error instanceof InsufficientScopeError) {
161
- const scope = error.extensions
162
- .requiredScope;
163
- if (scope) {
164
- parts.push(`scope="${quote(scope)}"`);
165
- }
178
+ const scope = requiredScope ||
179
+ (error instanceof InsufficientScopeError
180
+ ? error.extensions.requiredScope
181
+ : undefined);
182
+ const challengeScope = scope ? toChallengeScope(scope) : undefined;
183
+ if (challengeScope) {
184
+ parts.push(`scope="${challengeScope}"`);
166
185
  }
167
186
  return `Bearer ${parts.join(", ")}`;
168
187
  }
@@ -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.19.0",
3
+ "version": "0.21.0",
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",