@udibo/oauth2 0.20.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,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.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",