@udibo/oauth2 0.15.1 → 0.16.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.
@@ -96,7 +96,11 @@ export interface EncryptedCookieAuthRequestStorageOptions {
96
96
  /**
97
97
  * How many pending requests the cookie carries at once. A second sign-in
98
98
  * started in another tab would otherwise evict the first, so its callback
99
- * would fail. Defaults to 3; the oldest is dropped beyond that.
99
+ * would fail. Defaults to 3; the oldest is dropped beyond that, and also
100
+ * whenever keeping it would push the sealed cookie past what every browser
101
+ * stores (about 4 KB), since a browser silently drops an oversized cookie
102
+ * and every pending sign-in in it with it. The sign-in being started is
103
+ * never the one dropped. Must be a positive integer.
100
104
  */
101
105
  maxPending?: number;
102
106
  }
@@ -146,6 +150,9 @@ export declare class EncryptedCookieAuthRequestStorage implements AuthRequestSto
146
150
  * carries a `__Host-`/`__Secure-` prefix the other cookie options contradict,
147
151
  * or when {@link AuthRequestCookieOptions.sameSite} is `"None"` without
148
152
  * {@link AuthRequestCookieOptions.secure}.
153
+ * @throws {RangeError} When
154
+ * {@link EncryptedCookieAuthRequestStorageOptions.maxPending} is not a
155
+ * positive integer.
149
156
  */
150
157
  constructor(options: EncryptedCookieAuthRequestStorageOptions);
151
158
  /** Returns the storage bound to `c`, for the duration of that request. */
@@ -32,6 +32,8 @@ import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie
32
32
  const BASE_NAME = "oauth2_auth_request";
33
33
  const DEFAULT_TTL_MS = 10 * 60 * 1000;
34
34
  const DEFAULT_MAX_PENDING = 3;
35
+ // RFC 6265 §6.1 only guarantees 4096 bytes per cookie, attributes included.
36
+ const MAX_COOKIE_NAME_VALUE_BYTES = 3900;
35
37
  function cookieAttributes(options, maxAgeSeconds) {
36
38
  const { secure, path, domain } = resolvePrefixConstrainedAttributes(options);
37
39
  return {
@@ -81,6 +83,9 @@ export class EncryptedCookieAuthRequestStorage {
81
83
  * carries a `__Host-`/`__Secure-` prefix the other cookie options contradict,
82
84
  * or when {@link AuthRequestCookieOptions.sameSite} is `"None"` without
83
85
  * {@link AuthRequestCookieOptions.secure}.
86
+ * @throws {RangeError} When
87
+ * {@link EncryptedCookieAuthRequestStorageOptions.maxPending} is not a
88
+ * positive integer.
84
89
  */
85
90
  constructor(options) {
86
91
  this.#cookie = options.cookie;
@@ -94,6 +99,10 @@ export class EncryptedCookieAuthRequestStorage {
94
99
  });
95
100
  this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
96
101
  this.#maxPending = options.maxPending ?? DEFAULT_MAX_PENDING;
102
+ if (!(Number.isInteger(this.#maxPending) && this.#maxPending > 0)) {
103
+ throw new RangeError("EncryptedCookieAuthRequestStorage maxPending must be a positive " +
104
+ `integer, got ${this.#maxPending}.`);
105
+ }
97
106
  }
98
107
  #key() {
99
108
  return (this.#keyPromise ??= deriveAesKey(this.#secret));
@@ -108,15 +117,23 @@ export class EncryptedCookieAuthRequestStorage {
108
117
  const cutoff = Date.now() - this.#ttlMs;
109
118
  return Object.fromEntries(Object.entries(pending).filter(([, record]) => record.createdAt > cutoff));
110
119
  }
111
- async #write(c, pending) {
120
+ async #write(c, pending, keep) {
112
121
  const entries = Object.entries(pending)
113
- .sort(([, a], [, b]) => b.createdAt - a.createdAt)
122
+ .sort(([stateA, a], [stateB, b]) => Number(stateB === keep) - Number(stateA === keep) ||
123
+ b.createdAt - a.createdAt)
114
124
  .slice(0, this.#maxPending);
115
125
  if (entries.length === 0) {
116
126
  this.#clear(c);
117
127
  return;
118
128
  }
119
- setCookie(c, this.#name, await sealJson(await this.#key(), Object.fromEntries(entries)), cookieAttributes(this.#cookie, Math.ceil(this.#ttlMs / 1000)));
129
+ const key = await this.#key();
130
+ let sealed = await sealJson(key, Object.fromEntries(entries));
131
+ while (entries.length > 1 &&
132
+ this.#name.length + 1 + sealed.length > MAX_COOKIE_NAME_VALUE_BYTES) {
133
+ entries.pop();
134
+ sealed = await sealJson(key, Object.fromEntries(entries));
135
+ }
136
+ setCookie(c, this.#name, sealed, cookieAttributes(this.#cookie, Math.ceil(this.#ttlMs / 1000)));
120
137
  }
121
138
  #clear(c) {
122
139
  deleteCookie(c, this.#name, cookieAttributes(this.#cookie));
@@ -128,7 +145,7 @@ export class EncryptedCookieAuthRequestStorage {
128
145
  set: async (state, value) => {
129
146
  const pending = await this.#read(c);
130
147
  pending[state] = value;
131
- await this.#write(c, pending);
148
+ await this.#write(c, pending, state);
132
149
  },
133
150
  delete: async (state) => {
134
151
  const pending = await this.#read(c);
@@ -162,6 +162,9 @@ export interface HonoBffCookieOptions {
162
162
  * header, so it is guarded instead by a same-origin check that reads
163
163
  * `Sec-Fetch-Site` (accepting only `same-origin` and `none`) and falls back to
164
164
  * comparing `Origin`, else `Referer`, for clients that send no fetch metadata.
165
+ * That comparison accepts the request's own origin and the application's
166
+ * (see {@link HonoBffOptions.rpInitiatedLogout}), so the fallback still passes
167
+ * behind a TLS-terminating proxy that hands the BFF a plain-HTTP request.
165
168
  * `same-site` is rejected along with `cross-site`, so a `GET` logout link
166
169
  * followed from a sibling subdomain does not pass. That check **fails closed**:
167
170
  * a request carrying none of the three signals is rejected. Two residuals come
@@ -425,10 +428,18 @@ export interface HonoBffOptions {
425
428
  * `/auth/logout` — after destroying the local session — additionally
426
429
  * redirects the browser to the OP's `end_session_endpoint` with
427
430
  * `id_token_hint` (the session's id_token, if any), `client_id`, and a
428
- * `post_logout_redirect_uri` built from the resolved origin + the
431
+ * `post_logout_redirect_uri` built from the application's origin + the
429
432
  * open-redirect-guarded `return_to`. This ends the upstream IDP SSO session,
430
433
  * not just the local BFF session.
431
434
  *
435
+ * The application's origin is {@link resolveOrigin}'s, else the configured
436
+ * client `redirectUri`'s, else the request's — the last only when no
437
+ * `redirectUri` is configured or {@link deriveRedirectUri} is on, matching
438
+ * the origin sign-in sends. Behind a TLS-terminating proxy the request
439
+ * reports `http:`, so a deployment that derives its redirect URI there sets
440
+ * {@link resolveOrigin}; otherwise the URI would miss the registered
441
+ * `https:` one.
442
+ *
432
443
  * Defaults to `false` (local logout only): not every IDP supports it, and it
433
444
  * adds a redirect hop. When enabled but no end-session endpoint is known,
434
445
  * logout falls back to the plain local redirect and warns once via
@@ -558,6 +569,14 @@ export interface HonoBffOptions {
558
569
  forwardedParams?: readonly string[];
559
570
  /** Default `return_to` applied when the browser doesn't pass one. */
560
571
  defaultReturnTo?: string;
572
+ /**
573
+ * Longest `return_to` accepted, in characters. `/auth/login` stores the
574
+ * value with the pending sign-in before any authentication, so a longer one
575
+ * is replaced by {@link defaultReturnTo} rather than kept — at login,
576
+ * callback, logout and in {@link HonoBff.loginContinuation}. Defaults to
577
+ * 2048; `Infinity` accepts any length.
578
+ */
579
+ maxReturnToLength?: number;
561
580
  /**
562
581
  * Optional hook run after the authorization code is exchanged. Lets
563
582
  * apps enrich the session with database-backed user info before the
@@ -638,7 +657,9 @@ export declare class HonoBff {
638
657
  * {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
639
658
  * numeric `cookie.maxAge` is not positive or exceeds 400 days, when
640
659
  * {@linkcode HonoBffBackchannelOptions.maxBodyBytes} is not a positive
641
- * integer (a `RangeError`), or when
660
+ * integer (a `RangeError`), when
661
+ * {@linkcode HonoBffOptions.maxReturnToLength} is neither a positive
662
+ * integer nor `Infinity` (a `RangeError`), or when
642
663
  * {@linkcode HonoBffOptions.extraParams} or
643
664
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
644
665
  * parameter, gives one a name that is empty or not equal to its trimmed
@@ -56,7 +56,7 @@ import { inboundRequestWithHeaders } from "../../../utils/_inbound-request.js";
56
56
  import { timingSafeMatchIndex } from "../../../utils/_timing-safe.js";
57
57
  import { sha256Hash } from "../../../server/utils/hash.js";
58
58
  import { OAUTH2_CONTEXT_KEY } from "../_common.js";
59
- import { loginContinuation as computeLoginContinuation, safeReturnTo, } from "../../../utils/url.js";
59
+ import { DEFAULT_MAX_RETURN_TO_LENGTH, loginContinuation as computeLoginContinuation, safeReturnTo, } from "../../../utils/url.js";
60
60
  import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
61
61
  import { DEFAULT_SESSION_MAX_AGE_MS, MemorySessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.js";
62
62
  import { buildDownstreamHeaders, buildUpstreamHeaders, isInvalidTokenChallenge, resolveForwardHeaders, resolveProxyUrl, } from "./proxy.js";
@@ -289,6 +289,7 @@ export class HonoBff {
289
289
  #sessionMode;
290
290
  #csrf;
291
291
  #extraAuthorizeParams;
292
+ #maxReturnToLength;
292
293
  #refreshes = new Map();
293
294
  /**
294
295
  * Defaults are applied for the session store ({@linkcode MemorySessionStore}),
@@ -307,7 +308,9 @@ export class HonoBff {
307
308
  * {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
308
309
  * numeric `cookie.maxAge` is not positive or exceeds 400 days, when
309
310
  * {@linkcode HonoBffBackchannelOptions.maxBodyBytes} is not a positive
310
- * integer (a `RangeError`), or when
311
+ * integer (a `RangeError`), when
312
+ * {@linkcode HonoBffOptions.maxReturnToLength} is neither a positive
313
+ * integer nor `Infinity` (a `RangeError`), or when
311
314
  * {@linkcode HonoBffOptions.extraParams} or
312
315
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
313
316
  * parameter, gives one a name that is empty or not equal to its trimmed
@@ -330,6 +333,13 @@ export class HonoBff {
330
333
  this.#sessionMode = options.sessionMode ?? "own";
331
334
  this.#csrf = resolveCsrf(options.csrf);
332
335
  this.#extraAuthorizeParams = resolveExtraAuthorizeParams(options);
336
+ this.#maxReturnToLength =
337
+ options.maxReturnToLength ?? DEFAULT_MAX_RETURN_TO_LENGTH;
338
+ if (this.#maxReturnToLength !== Infinity &&
339
+ !(Number.isInteger(this.#maxReturnToLength) && this.#maxReturnToLength > 0)) {
340
+ throw new RangeError("HonoBff maxReturnToLength must be a positive integer or Infinity, " +
341
+ `got ${this.#maxReturnToLength}.`);
342
+ }
333
343
  const sessionMaxAgeMs = positiveMs(options.sessionMaxAgeMs ?? DEFAULT_SESSION_MAX_AGE_MS, "sessionMaxAgeMs");
334
344
  this.#sessionMaxAgeMs = sessionMaxAgeMs;
335
345
  if (this.#sessionMode === "shared") {
@@ -454,6 +464,20 @@ export class HonoBff {
454
464
  }
455
465
  setCookie(c, this.#loginStateCookieName, hashes.slice(-MAX_PENDING_LOGIN_STATES).join(LOGIN_STATE_SEPARATOR), loginStateCookieOpts(this.#options.cookie, Math.ceil(this.#loginStateTtlMs / 1000)));
456
466
  }
467
+ #safeReturnTo(value) {
468
+ return safeReturnTo(value, this.#options.defaultReturnTo ?? "/", {
469
+ maxLength: this.#maxReturnToLength,
470
+ });
471
+ }
472
+ #appOrigin(c) {
473
+ const resolved = this.#options.resolveOrigin?.(c);
474
+ if (resolved !== undefined)
475
+ return resolved;
476
+ const configured = this.#options.deriveRedirectUri
477
+ ? undefined
478
+ : this.#client.redirectUri;
479
+ return new URL(configured ?? c.req.url).origin;
480
+ }
457
481
  #derivedRedirectUri(c, origin) {
458
482
  if (!this.#options.deriveRedirectUri)
459
483
  return undefined;
@@ -473,12 +497,12 @@ export class HonoBff {
473
497
  const fetchSite = c.req.header("sec-fetch-site");
474
498
  if (fetchSite)
475
499
  return fetchSite === "same-origin" || fetchSite === "none";
476
- const target = new URL(c.req.url).origin;
500
+ const targets = [new URL(c.req.url).origin, this.#appOrigin(c)];
477
501
  const check = (val) => {
478
502
  if (!val)
479
503
  return undefined;
480
504
  try {
481
- return new URL(val).origin === target;
505
+ return targets.includes(new URL(val).origin);
482
506
  }
483
507
  catch {
484
508
  return false;
@@ -555,6 +579,7 @@ export class HonoBff {
555
579
  authorizeEndpoint: this.#client.authorizationEndpoint,
556
580
  loginPath: this.#paths.login,
557
581
  defaultReturnTo: this.#options.defaultReturnTo,
582
+ maxReturnToLength: this.#maxReturnToLength,
558
583
  });
559
584
  }
560
585
  #pathsRelative() {
@@ -583,7 +608,7 @@ export class HonoBff {
583
608
  loginHandler() {
584
609
  return async (c) => {
585
610
  this.#noStore(c);
586
- const returnTo = safeReturnTo(c.req.query("return_to"), this.#options.defaultReturnTo ?? "/");
611
+ const returnTo = this.#safeReturnTo(c.req.query("return_to"));
587
612
  const origin = this.#options.resolveOrigin?.(c);
588
613
  const requested = authorizeParamsForRequest(this.#extraAuthorizeParams, new URL(c.req.url).searchParams);
589
614
  const { url, state } = await this.#client.login({
@@ -683,7 +708,7 @@ export class HonoBff {
683
708
  if (cookieValue !== existing) {
684
709
  setCookie(c, this.#cookieName, cookieValue, this.#cookieOptions());
685
710
  }
686
- return c.redirect(safeReturnTo(returnTo, this.#options.defaultReturnTo ?? "/"));
711
+ return c.redirect(this.#safeReturnTo(returnTo));
687
712
  }
688
713
  catch (cause) {
689
714
  return await fail({
@@ -723,11 +748,11 @@ export class HonoBff {
723
748
  await this.#store.destroy(cookieValue);
724
749
  deleteCookie(c, this.#cookieName, this.#cookieOptions());
725
750
  }
726
- const returnTo = safeReturnTo(c.req.query("return_to"), this.#options.defaultReturnTo ?? "/");
751
+ const returnTo = this.#safeReturnTo(c.req.query("return_to"));
727
752
  if (this.#options.rpInitiatedLogout) {
728
753
  const endSession = this.#client.endSessionEndpoint;
729
754
  if (endSession) {
730
- const origin = this.#options.resolveOrigin?.(c) ?? new URL(c.req.url).origin;
755
+ const origin = this.#appOrigin(c);
731
756
  const url = new URL(endSession, origin);
732
757
  if (idToken)
733
758
  url.searchParams.set("id_token_hint", idToken);
@@ -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 });
@@ -342,6 +342,15 @@ export declare class DirectClient extends OAuth2ClientBase {
342
342
  * available. It is a public identifier, never a credential.
343
343
  */
344
344
  get clientId(): string;
345
+ /**
346
+ * The configured `redirect_uri`, or `undefined` when none was given.
347
+ *
348
+ * Exposed so a Backend-for-Frontend behind a TLS-terminating proxy can
349
+ * take the application's public origin from configuration rather than from
350
+ * a request URL that reports the proxy's plain-HTTP hop. See
351
+ * `HonoBffOptions.rpInitiatedLogout`.
352
+ */
353
+ get redirectUri(): string | undefined;
345
354
  /**
346
355
  * Builds the PKCE-protected authorize URL and persists the verifier under
347
356
  * the returned `state`. Navigate the browser to `url`; the callback route
@@ -280,6 +280,17 @@ export class DirectClient extends OAuth2ClientBase {
280
280
  get clientId() {
281
281
  return this.#clientId;
282
282
  }
283
+ /**
284
+ * The configured `redirect_uri`, or `undefined` when none was given.
285
+ *
286
+ * Exposed so a Backend-for-Frontend behind a TLS-terminating proxy can
287
+ * take the application's public origin from configuration rather than from
288
+ * a request URL that reports the proxy's plain-HTTP hop. See
289
+ * `HonoBffOptions.rpInitiatedLogout`.
290
+ */
291
+ get redirectUri() {
292
+ return this.#redirectUri;
293
+ }
283
294
  async #endpoint(key) {
284
295
  if (this.#issuer && !this.#hasFreshMetadata())
285
296
  await this.discover();
@@ -135,17 +135,35 @@ export interface MemoryAuthRequestStorageOptions {
135
135
  * in ms. Defaults to 10 minutes; `Infinity` never prunes.
136
136
  */
137
137
  ttlMs?: number;
138
+ /**
139
+ * Most records held at once. A write beyond it evicts the oldest-written
140
+ * record, so a flood of sign-in starts that never reach the callback cannot
141
+ * grow the process without bound — at the cost of failing each evicted
142
+ * sign-in with an unknown `state`. Defaults to 1000; `Infinity` never
143
+ * evicts by count.
144
+ */
145
+ maxEntries?: number;
138
146
  }
139
147
  /**
140
148
  * In-memory {@link AuthRequestStorage}, `DirectClient`'s default outside a
141
149
  * browser document. Data is lost when the process exits. Every write prunes
142
- * records older than {@link MemoryAuthRequestStorageOptions.ttlMs}, so logins
143
- * that never return to the callback do not accumulate in a long-running
144
- * server.
150
+ * records older than {@link MemoryAuthRequestStorageOptions.ttlMs}, then
151
+ * evicts the oldest-written records beyond
152
+ * {@link MemoryAuthRequestStorageOptions.maxEntries}, so neither logins that
153
+ * never return to the callback nor an anonymous burst of sign-in starts can
154
+ * grow a long-running server without bound. A deployment with more than one
155
+ * instance, or more pending sign-ins than the cap, needs storage every
156
+ * instance shares instead.
145
157
  */
146
158
  export declare class MemoryAuthRequestStorage implements AuthRequestStorage {
147
159
  #private;
148
- /** Configures the pruning TTL, 10 minutes by default. */
160
+ /**
161
+ * Configures the pruning TTL (10 minutes by default) and the record cap
162
+ * (1000 by default).
163
+ *
164
+ * @throws {RangeError} When `maxEntries` is neither a positive integer nor
165
+ * `Infinity`.
166
+ */
149
167
  constructor(options?: MemoryAuthRequestStorageOptions);
150
168
  /** Returns the record for {@linkcode state}, or `null` if none is stored. */
151
169
  get(state: string): AuthRequestRecord | null;
@@ -153,7 +171,8 @@ export declare class MemoryAuthRequestStorage implements AuthRequestStorage {
153
171
  take(state: string): AuthRequestRecord | null;
154
172
  /**
155
173
  * Stores {@linkcode value} under {@linkcode state}, first pruning records
156
- * past the TTL. Pruning walks records oldest-write-first and stops at the
174
+ * past the TTL, then evicting the oldest-written records beyond
175
+ * `maxEntries`. Pruning walks records oldest-write-first and stops at the
157
176
  * first one inside the TTL, so it assumes each `createdAt` is the time of
158
177
  * its write, as `DirectClient.login` sets it.
159
178
  */
@@ -53,19 +53,37 @@ export class MemoryRefreshTokenStorage {
53
53
  this.#token = null;
54
54
  }
55
55
  }
56
+ const DEFAULT_MAX_AUTH_REQUESTS = 1000;
56
57
  /**
57
58
  * In-memory {@link AuthRequestStorage}, `DirectClient`'s default outside a
58
59
  * browser document. Data is lost when the process exits. Every write prunes
59
- * records older than {@link MemoryAuthRequestStorageOptions.ttlMs}, so logins
60
- * that never return to the callback do not accumulate in a long-running
61
- * server.
60
+ * records older than {@link MemoryAuthRequestStorageOptions.ttlMs}, then
61
+ * evicts the oldest-written records beyond
62
+ * {@link MemoryAuthRequestStorageOptions.maxEntries}, so neither logins that
63
+ * never return to the callback nor an anonymous burst of sign-in starts can
64
+ * grow a long-running server without bound. A deployment with more than one
65
+ * instance, or more pending sign-ins than the cap, needs storage every
66
+ * instance shares instead.
62
67
  */
63
68
  export class MemoryAuthRequestStorage {
64
69
  #records = new Map();
65
70
  #ttlMs;
66
- /** Configures the pruning TTL, 10 minutes by default. */
71
+ #maxEntries;
72
+ /**
73
+ * Configures the pruning TTL (10 minutes by default) and the record cap
74
+ * (1000 by default).
75
+ *
76
+ * @throws {RangeError} When `maxEntries` is neither a positive integer nor
77
+ * `Infinity`.
78
+ */
67
79
  constructor(options = {}) {
68
80
  this.#ttlMs = options.ttlMs ?? 10 * 60 * 1000;
81
+ this.#maxEntries = options.maxEntries ?? DEFAULT_MAX_AUTH_REQUESTS;
82
+ if (this.#maxEntries !== Infinity &&
83
+ !(Number.isInteger(this.#maxEntries) && this.#maxEntries > 0)) {
84
+ throw new RangeError("MemoryAuthRequestStorage maxEntries must be a positive integer or " +
85
+ `Infinity, got ${this.#maxEntries}.`);
86
+ }
69
87
  }
70
88
  /** Returns the record for {@linkcode state}, or `null` if none is stored. */
71
89
  get(state) {
@@ -79,7 +97,8 @@ export class MemoryAuthRequestStorage {
79
97
  }
80
98
  /**
81
99
  * Stores {@linkcode value} under {@linkcode state}, first pruning records
82
- * past the TTL. Pruning walks records oldest-write-first and stops at the
100
+ * past the TTL, then evicting the oldest-written records beyond
101
+ * `maxEntries`. Pruning walks records oldest-write-first and stops at the
83
102
  * first one inside the TTL, so it assumes each `createdAt` is the time of
84
103
  * its write, as `DirectClient.login` sets it.
85
104
  */
@@ -87,6 +106,7 @@ export class MemoryAuthRequestStorage {
87
106
  this.#pruneExpired();
88
107
  this.#records.delete(state);
89
108
  this.#records.set(state, value);
109
+ this.#evictOverflow();
90
110
  }
91
111
  /** Removes the record for {@linkcode state}. */
92
112
  delete(state) {
@@ -96,6 +116,13 @@ export class MemoryAuthRequestStorage {
96
116
  clear() {
97
117
  this.#records.clear();
98
118
  }
119
+ #evictOverflow() {
120
+ for (const state of this.#records.keys()) {
121
+ if (this.#records.size <= this.#maxEntries)
122
+ return;
123
+ this.#records.delete(state);
124
+ }
125
+ }
99
126
  #pruneExpired() {
100
127
  if (this.#ttlMs === Infinity)
101
128
  return;
@@ -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 = [];
@@ -3,6 +3,17 @@
3
3
  *
4
4
  * @module
5
5
  */
6
+ /** {@link SafeReturnToOptions.maxLength}'s default, in UTF-16 code units. */
7
+ export declare const DEFAULT_MAX_RETURN_TO_LENGTH = 2048;
8
+ /** Options for {@link safeReturnTo}. */
9
+ export interface SafeReturnToOptions {
10
+ /**
11
+ * Longest value accepted, in UTF-16 code units (`String.length`), not
12
+ * bytes; a longer one collapses to the fallback. Defaults to {@link DEFAULT_MAX_RETURN_TO_LENGTH} (2048).
13
+ * `Infinity` accepts any length.
14
+ */
15
+ maxLength?: number;
16
+ }
6
17
  /**
7
18
  * Open-redirect guard for a `return_to` / redirect target.
8
19
  *
@@ -19,6 +30,11 @@
19
30
  * Percent-encoded control characters (`/%09/x`) are safe and pass through: a
20
31
  * browser does not decode them before parsing, so they stay on the origin.
21
32
  *
33
+ * A value longer than {@link SafeReturnToOptions.maxLength} (2048 characters
34
+ * by default) also collapses to `fallback`, so a caller that stores the
35
+ * result — a pending sign-in, a session — cannot be made to hold an
36
+ * arbitrarily large string per anonymous request.
37
+ *
22
38
  * Use this anywhere a user-influenced value — a `return_to` / `redirect` query
23
39
  * param or form field — is fed into a redirect `Location`, so a crafted
24
40
  * absolute target can't turn the (often post-authentication) redirect into an
@@ -32,9 +48,10 @@
32
48
  * safeReturnTo("/\\evil.com"); // → "/"
33
49
  * safeReturnTo("/\t/evil.com"); // → "/"
34
50
  * safeReturnTo(undefined, "/home"); // → "/home"
51
+ * safeReturnTo("/a-long-path", "/", { maxLength: 8 }); // → "/"
35
52
  * ```
36
53
  */
37
- export declare function safeReturnTo(value: string | null | undefined, fallback?: string): string;
54
+ export declare function safeReturnTo(value: string | null | undefined, fallback?: string, options?: SafeReturnToOptions): string;
38
55
  /** Trusted application configuration for {@link loginContinuation}. */
39
56
  export interface LoginContinuationOptions {
40
57
  /**
@@ -47,6 +64,11 @@ export interface LoginContinuationOptions {
47
64
  loginPath?: string;
48
65
  /** Trusted fallback for missing/unsafe `returnTo`. Defaults to `"/"`. */
49
66
  defaultReturnTo?: string;
67
+ /**
68
+ * Longest `returnTo` accepted, in characters; a longer one falls back to
69
+ * {@link defaultReturnTo}. Defaults to 2048 ({@link safeReturnTo}'s cap).
70
+ */
71
+ maxReturnToLength?: number;
50
72
  }
51
73
  /**
52
74
  * The URL that (re)starts sign-in toward `returnTo`, for an app that owns its
package/dist/utils/url.js CHANGED
@@ -4,6 +4,8 @@
4
4
  * @module
5
5
  */
6
6
  const RESOLUTION_ORIGIN = "http://safe-return-to.invalid";
7
+ /** {@link SafeReturnToOptions.maxLength}'s default, in UTF-16 code units. */
8
+ export const DEFAULT_MAX_RETURN_TO_LENGTH = 2048;
7
9
  function hasControlCharacter(value) {
8
10
  for (let index = 0; index < value.length; index++) {
9
11
  const code = value.charCodeAt(index);
@@ -28,6 +30,11 @@ function hasControlCharacter(value) {
28
30
  * Percent-encoded control characters (`/%09/x`) are safe and pass through: a
29
31
  * browser does not decode them before parsing, so they stay on the origin.
30
32
  *
33
+ * A value longer than {@link SafeReturnToOptions.maxLength} (2048 characters
34
+ * by default) also collapses to `fallback`, so a caller that stores the
35
+ * result — a pending sign-in, a session — cannot be made to hold an
36
+ * arbitrarily large string per anonymous request.
37
+ *
31
38
  * Use this anywhere a user-influenced value — a `return_to` / `redirect` query
32
39
  * param or form field — is fed into a redirect `Location`, so a crafted
33
40
  * absolute target can't turn the (often post-authentication) redirect into an
@@ -41,11 +48,15 @@ function hasControlCharacter(value) {
41
48
  * safeReturnTo("/\\evil.com"); // → "/"
42
49
  * safeReturnTo("/\t/evil.com"); // → "/"
43
50
  * safeReturnTo(undefined, "/home"); // → "/home"
51
+ * safeReturnTo("/a-long-path", "/", { maxLength: 8 }); // → "/"
44
52
  * ```
45
53
  */
46
- export function safeReturnTo(value, fallback = "/") {
54
+ export function safeReturnTo(value, fallback = "/", options = {}) {
47
55
  if (!value || !value.startsWith("/"))
48
56
  return fallback;
57
+ if (value.length > (options.maxLength ?? DEFAULT_MAX_RETURN_TO_LENGTH)) {
58
+ return fallback;
59
+ }
49
60
  if (hasControlCharacter(value))
50
61
  return fallback;
51
62
  let resolved;
@@ -79,7 +90,9 @@ export function safeReturnTo(value, fallback = "/") {
79
90
  * endpoint and login path are trusted application configuration.
80
91
  */
81
92
  export function loginContinuation(returnTo, options = {}) {
82
- const safe = safeReturnTo(returnTo, options.defaultReturnTo ?? "/");
93
+ const safe = safeReturnTo(returnTo, options.defaultReturnTo ?? "/", {
94
+ maxLength: options.maxReturnToLength,
95
+ });
83
96
  if (options.authorizeEndpoint) {
84
97
  const base = "http://login.continuation.local";
85
98
  const authorizePath = new URL(options.authorizeEndpoint, base).pathname;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.15.1",
3
+ "version": "0.16.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",