@udibo/oauth2 0.15.1 → 0.15.2

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.
@@ -88,6 +88,23 @@ export interface HonoIdentityOptions<User extends IdentityUser> {
88
88
  * outer middleware already terminates CSRF.
89
89
  */
90
90
  csrf?: HonoIdentityCsrfOptions | false;
91
+ /**
92
+ * The most bytes a route reads from a request body. Every route reads its
93
+ * body before anyone is authenticated, so the cap bounds what an anonymous
94
+ * request can make the server buffer. Bytes are counted as they arrive, so a
95
+ * chunked body or an understated `Content-Length` cannot get past it. A
96
+ * larger body is refused with `413` `{ error: "invalid_request" }` before
97
+ * the service is called.
98
+ *
99
+ * When a middleware mounted before these routes has already read the body
100
+ * (for example with `c.req.parseBody()`), the routes read Hono's parsed copy
101
+ * and the cap does not apply, so that middleware needs its own limit.
102
+ *
103
+ * @default 65536
104
+ * @throws {RangeError} From {@linkcode honoIdentityRoutes} when it is not a
105
+ * positive integer.
106
+ */
107
+ maxBodyBytes?: number;
91
108
  /**
92
109
  * Called after `signup`/`signin` authenticates a user. Your app owns what
93
110
  * happens next — create your session and return a `Response` (e.g. a redirect
@@ -112,7 +129,12 @@ export interface HonoIdentityOptions<User extends IdentityUser> {
112
129
  * Every mounted route sits behind the same-origin guard described in
113
130
  * {@link HonoIdentityOptions.csrf}, which is on unless you pass `csrf: false`.
114
131
  *
132
+ * Every route reads at most {@link HonoIdentityOptions.maxBodyBytes} of its
133
+ * body and answers a larger one with `413`.
134
+ *
115
135
  * Passwordless, MFA, and social are intentionally not mounted — see the module
116
136
  * doc for why and where to hand-wire each.
137
+ *
138
+ * @throws {RangeError} If `maxBodyBytes` is not a positive integer.
117
139
  */
118
140
  export declare function honoIdentityRoutes<User extends IdentityUser>(service: IdentityService<User>, options?: HonoIdentityOptions<User>): Hono;
@@ -41,12 +41,16 @@
41
41
  */
42
42
  import { Hono } from "hono";
43
43
  import { identityErrorStatus, isIdentityError, } from "../../identity/errors.js";
44
+ import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../../utils/_body.js";
44
45
  function handle(fn) {
45
46
  return async (c) => {
46
47
  try {
47
48
  return await fn(c);
48
49
  }
49
50
  catch (error) {
51
+ if (error instanceof BodyTooLargeError) {
52
+ return c.json({ error: "invalid_request" }, 413);
53
+ }
50
54
  if (!isIdentityError(error))
51
55
  throw error;
52
56
  if (error.retryAfterMs !== undefined) {
@@ -57,11 +61,24 @@ function handle(fn) {
57
61
  };
58
62
  }
59
63
  const STRING_FIELDS = new Set(["password", "identifier", "email", "token"]);
60
- async function readBody(c) {
64
+ class BodyTooLargeError extends Error {
65
+ }
66
+ async function boundedBody(c, maxBytes) {
67
+ if (c.req.raw.bodyUsed)
68
+ return c.req;
69
+ const bytes = await readBoundedBody(c.req.raw, maxBytes).catch(() => new Uint8Array());
70
+ if (!bytes)
71
+ throw new BodyTooLargeError();
72
+ return new Response(bytes, {
73
+ headers: { "content-type": c.req.header("content-type") ?? "" },
74
+ });
75
+ }
76
+ async function readBody(c, maxBytes) {
61
77
  const contentType = c.req.header("content-type") ?? "";
78
+ const source = await boundedBody(c, maxBytes);
62
79
  if (contentType.includes("application/json")) {
63
80
  try {
64
- const body = await c.req.json();
81
+ const body = await source.json();
65
82
  if (typeof body !== "object" || body === null || Array.isArray(body)) {
66
83
  return {};
67
84
  }
@@ -79,7 +96,7 @@ async function readBody(c) {
79
96
  }
80
97
  }
81
98
  try {
82
- const form = await c.req.formData();
99
+ const form = await source.formData();
83
100
  const out = {};
84
101
  for (const [key, value] of form)
85
102
  out[key] = String(value);
@@ -135,10 +152,17 @@ function sameOriginGuard(options) {
135
152
  * Every mounted route sits behind the same-origin guard described in
136
153
  * {@link HonoIdentityOptions.csrf}, which is on unless you pass `csrf: false`.
137
154
  *
155
+ * Every route reads at most {@link HonoIdentityOptions.maxBodyBytes} of its
156
+ * body and answers a larger one with `413`.
157
+ *
138
158
  * Passwordless, MFA, and social are intentionally not mounted — see the module
139
159
  * doc for why and where to hand-wire each.
160
+ *
161
+ * @throws {RangeError} If `maxBodyBytes` is not a positive integer.
140
162
  */
141
163
  export function honoIdentityRoutes(service, options = {}) {
164
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
165
+ assertMaxBodyBytes(maxBodyBytes, "maxBodyBytes");
142
166
  const app = new Hono();
143
167
  const paths = {
144
168
  signUp: "/signup",
@@ -154,7 +178,7 @@ export function honoIdentityRoutes(service, options = {}) {
154
178
  }
155
179
  if (onAuthenticated) {
156
180
  app.post(paths.signUp, handle(async (c) => {
157
- const { password, ...profile } = await readBody(c);
181
+ const { password, ...profile } = await readBody(c, maxBodyBytes);
158
182
  if (typeof password !== "string" || !password) {
159
183
  return c.json({ error: "invalid_request" }, 400);
160
184
  }
@@ -162,7 +186,7 @@ export function honoIdentityRoutes(service, options = {}) {
162
186
  return await onAuthenticated(c, user, "signUp");
163
187
  }));
164
188
  app.post(paths.signIn, handle(async (c) => {
165
- const body = await readBody(c);
189
+ const body = await readBody(c, maxBodyBytes);
166
190
  const user = await service.signIn({
167
191
  identifier: String(body.identifier ?? ""),
168
192
  password: String(body.password ?? ""),
@@ -173,12 +197,12 @@ export function honoIdentityRoutes(service, options = {}) {
173
197
  }));
174
198
  }
175
199
  app.post(paths.requestPasswordReset, handle(async (c) => {
176
- const body = await readBody(c);
200
+ const body = await readBody(c, maxBodyBytes);
177
201
  await service.requestPasswordReset(String(body.email ?? ""));
178
202
  return c.json({ ok: true });
179
203
  }));
180
204
  app.post(paths.resetPassword, handle(async (c) => {
181
- const body = await readBody(c);
205
+ const body = await readBody(c, maxBodyBytes);
182
206
  const result = await service.resetPassword({
183
207
  token: String(body.token ?? ""),
184
208
  password: String(body.password ?? ""),
@@ -188,7 +212,7 @@ export function honoIdentityRoutes(service, options = {}) {
188
212
  : c.json({ error: "invalid_token" }, 400);
189
213
  }));
190
214
  app.post(paths.verifyEmail, handle(async (c) => {
191
- const body = await readBody(c);
215
+ const body = await readBody(c, maxBodyBytes);
192
216
  const result = await service.verifyEmail(String(body.token ?? ""));
193
217
  if (result.status === "success") {
194
218
  return c.json({ ok: true, userId: result.userId });
@@ -444,7 +444,8 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
444
444
  *
445
445
  * @throws {Error} If any grant's map key does not equal its `grantType`.
446
446
  * @throws {Error} If `requireOidc` is set without `signingKeys`.
447
- * @throws {RangeError} If `maxBodyBytes` is not a positive integer.
447
+ * @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
448
+ * positive integer.
448
449
  */
449
450
  constructor(options: AuthorizationServerOptions<Client, User, S>);
450
451
  /**
@@ -197,7 +197,8 @@ export class AuthorizationServer extends ResourceServer {
197
197
  *
198
198
  * @throws {Error} If any grant's map key does not equal its `grantType`.
199
199
  * @throws {Error} If `requireOidc` is set without `signingKeys`.
200
- * @throws {RangeError} If `maxBodyBytes` is not a positive integer.
200
+ * @throws {RangeError} If `maxBodyBytes` or `maxTokenBodyBytes` is not a
201
+ * positive integer.
201
202
  */
202
203
  constructor(options) {
203
204
  super({
@@ -205,6 +206,7 @@ export class AuthorizationServer extends ResourceServer {
205
206
  Scope: options.Scope,
206
207
  realm: options.realm,
207
208
  clockSkewSeconds: options.clockSkewSeconds,
209
+ maxTokenBodyBytes: options.maxTokenBodyBytes,
208
210
  errorFormat: options.errorFormat,
209
211
  throwOnError: options.throwOnError,
210
212
  });
@@ -55,6 +55,27 @@ export interface ResourceServerOptions<Client extends ClientInterface, User, S e
55
55
  * matter which reader is in play.
56
56
  */
57
57
  clockSkewSeconds?: number;
58
+ /**
59
+ * The most bytes {@linkcode ResourceServer.getAccessToken} reads from a
60
+ * form-encoded `POST` body while looking for an RFC 6750 §2.2
61
+ * `access_token`, which it does only when no bearer `Authorization` header
62
+ * is sent. That read happens before the caller is authenticated, so the cap
63
+ * bounds what an anonymous request can make the server buffer, including
64
+ * through `protect()` middleware mounted ahead of a route's own body limit.
65
+ * Bytes are counted as they arrive, so a chunked body or an understated
66
+ * `Content-Length` cannot get past it.
67
+ *
68
+ * A larger body is not searched: the request is treated as carrying no
69
+ * token (`authenticate` throws {@linkcode AccessDeniedError}, a `401`), and
70
+ * the body is left for the route. A client that sends a bigger form body
71
+ * should send its token in the `Authorization` header instead, as RFC 6750
72
+ * §2.1 recommends.
73
+ *
74
+ * @default 16384
75
+ * @throws {RangeError} From the constructor when it is not a positive
76
+ * integer.
77
+ */
78
+ maxTokenBodyBytes?: number;
58
79
  /**
59
80
  * Format for error responses.
60
81
  * - `"oauth2"` (default): Standard OAuth2 error format with `error`,
@@ -162,7 +183,8 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
162
183
  * Creates a resource server from the given {@linkcode ResourceServerOptions}.
163
184
  *
164
185
  * @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
165
- * overflows when converted to milliseconds.
186
+ * overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
187
+ * not a positive integer.
166
188
  */
167
189
  constructor(options: ResourceServerOptions<Client, User, S>);
168
190
  /**
@@ -239,8 +261,11 @@ export declare class ResourceServer<Client extends ClientInterface, User, S exte
239
261
  handleAuthError(error: unknown): Response;
240
262
  /**
241
263
  * Extracts an access token from a request.
242
- * Looks in the Authorization header (Bearer token) first,
243
- * then in the request body for form-encoded requests.
264
+ * Looks in the Authorization header (Bearer token) first, then in the body
265
+ * of a form-encoded `POST` (RFC 6750 §2.2), read from a clone so the
266
+ * original body stays readable. A body over
267
+ * {@linkcode ResourceServerOptions.maxTokenBodyBytes} is not searched, and
268
+ * no more of it is read than the cap.
244
269
  */
245
270
  getAccessToken(request: Request): Promise<string | null>;
246
271
  /**
@@ -10,6 +10,7 @@ import { BasicScope } from "../models/scope.js";
10
10
  import { AccessDeniedError, InsufficientPermissionsError, InsufficientScopeError, InvalidTokenError, toOAuth2Error, } from "../errors.js";
11
11
  import { authorizationFromClaims, } from "../models/authorization.js";
12
12
  import { assertClockSkewSeconds } from "./_clock-skew.js";
13
+ import { assertMaxBodyBytes, DEFAULT_MAX_TOKEN_BODY_BYTES, peekBoundedBody, } from "../utils/_body.js";
13
14
  /** Bearer token regex pattern. */
14
15
  export const BEARER_TOKEN = /^ *(?:[Bb][Ee][Aa][Rr][Ee][Rr]) +([\w-.~+/]+=*) *$/;
15
16
  const FAILURE_LABELS = {
@@ -80,6 +81,7 @@ export class ResourceServer {
80
81
  /** Resolves the per-request {@link ResourceServerContext}. */
81
82
  resolveContext;
82
83
  #clockSkewSeconds = 0;
84
+ #maxTokenBodyBytes;
83
85
  /**
84
86
  * Leeway in seconds applied to the access token's expiry. Defaults to `0`.
85
87
  * May be reassigned to any non-negative finite number with a finite
@@ -102,13 +104,17 @@ export class ResourceServer {
102
104
  * Creates a resource server from the given {@linkcode ResourceServerOptions}.
103
105
  *
104
106
  * @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
105
- * overflows when converted to milliseconds.
107
+ * overflows when converted to milliseconds, or if `maxTokenBodyBytes` is
108
+ * not a positive integer.
106
109
  */
107
110
  constructor(options) {
108
111
  this.Scope =
109
112
  options.Scope ?? BasicScope;
110
113
  this.realm = options.realm ?? "Service";
111
114
  this.clockSkewSeconds = options.clockSkewSeconds ?? 0;
115
+ this.#maxTokenBodyBytes =
116
+ options.maxTokenBodyBytes ?? DEFAULT_MAX_TOKEN_BODY_BYTES;
117
+ assertMaxBodyBytes(this.#maxTokenBodyBytes, "maxTokenBodyBytes");
112
118
  this.errorFormat = options.errorFormat ?? "oauth2";
113
119
  this.throwOnError = options.throwOnError ?? false;
114
120
  this.resolveContext = options.resolve;
@@ -252,8 +258,11 @@ export class ResourceServer {
252
258
  }
253
259
  /**
254
260
  * Extracts an access token from a request.
255
- * Looks in the Authorization header (Bearer token) first,
256
- * then in the request body for form-encoded requests.
261
+ * Looks in the Authorization header (Bearer token) first, then in the body
262
+ * of a form-encoded `POST` (RFC 6750 §2.2), read from a clone so the
263
+ * original body stays readable. A body over
264
+ * {@linkcode ResourceServerOptions.maxTokenBodyBytes} is not searched, and
265
+ * no more of it is read than the cap.
257
266
  */
258
267
  async getAccessToken(request) {
259
268
  const authorization = request.headers.get("authorization");
@@ -266,13 +275,18 @@ export class ResourceServer {
266
275
  if (request.method === "POST" &&
267
276
  contentType?.includes("application/x-www-form-urlencoded")) {
268
277
  try {
269
- const body = await request.clone().formData();
278
+ const bytes = await peekBoundedBody(request, this.#maxTokenBodyBytes);
279
+ if (!bytes)
280
+ return null;
281
+ const body = await new Response(bytes, {
282
+ headers: { "content-type": contentType },
283
+ }).formData();
270
284
  const accessToken = body.get("access_token");
271
285
  if (typeof accessToken === "string")
272
286
  return accessToken;
273
287
  }
274
288
  catch {
275
- // ignore
289
+ return null;
276
290
  }
277
291
  }
278
292
  return null;
@@ -11,6 +11,12 @@
11
11
  * the 8–16 KiB header limits common to servers and proxies.
12
12
  */
13
13
  export declare const DEFAULT_MAX_BODY_BYTES: number;
14
+ /**
15
+ * The default cap on a form body searched for an RFC 6750 §2.2
16
+ * `access_token`: 16 KiB, Node's default limit on a request's whole header
17
+ * block, so any token a client could send as `Authorization: Bearer` fits.
18
+ */
19
+ export declare const DEFAULT_MAX_TOKEN_BODY_BYTES: number;
14
20
  /**
15
21
  * Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
16
22
  * safe integer.
@@ -27,3 +33,12 @@ export declare function assertMaxBodyBytes(maxBodyBytes: number, option: string)
27
33
  export declare function readBoundedBody(request: Request, maxBytes: number, options?: {
28
34
  keepReadable?: boolean;
29
35
  }): Promise<Uint8Array<ArrayBuffer> | null>;
36
+ /**
37
+ * Reads at most `maxBytes` of `request`'s body from a clone and returns
38
+ * `null` once it exceeds `maxBytes`, cancelling only the clone. The original
39
+ * stays readable either way, holding no more than what the clone pulled, so
40
+ * the caller's own handling of the body — or of its absence — is unchanged.
41
+ * A `Content-Length` over the cap returns `null` without reading. Rejects if
42
+ * the body was already read.
43
+ */
44
+ export declare function peekBoundedBody(request: Request, maxBytes: number): Promise<Uint8Array<ArrayBuffer> | null>;
@@ -11,6 +11,12 @@
11
11
  * the 8–16 KiB header limits common to servers and proxies.
12
12
  */
13
13
  export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
14
+ /**
15
+ * The default cap on a form body searched for an RFC 6750 §2.2
16
+ * `access_token`: 16 KiB, Node's default limit on a request's whole header
17
+ * block, so any token a client could send as `Authorization: Bearer` fits.
18
+ */
19
+ export const DEFAULT_MAX_TOKEN_BODY_BYTES = 16 * 1024;
14
20
  /**
15
21
  * Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
16
22
  * safe integer.
@@ -41,6 +47,21 @@ export async function readBoundedBody(request, maxBytes, options = {}) {
41
47
  request.body.cancel().catch(() => { });
42
48
  return bytes;
43
49
  }
50
+ /**
51
+ * Reads at most `maxBytes` of `request`'s body from a clone and returns
52
+ * `null` once it exceeds `maxBytes`, cancelling only the clone. The original
53
+ * stays readable either way, holding no more than what the clone pulled, so
54
+ * the caller's own handling of the body — or of its absence — is unchanged.
55
+ * A `Content-Length` over the cap returns `null` without reading. Rejects if
56
+ * the body was already read.
57
+ */
58
+ export async function peekBoundedBody(request, maxBytes) {
59
+ if (!request.body)
60
+ return new Uint8Array();
61
+ if (Number(request.headers.get("content-length")) > maxBytes)
62
+ return null;
63
+ return await readUpTo(request.clone().body, maxBytes);
64
+ }
44
65
  async function readUpTo(body, maxBytes) {
45
66
  const reader = body.getReader();
46
67
  const chunks = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
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",