@udibo/oauth2 0.15.2 → 0.17.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.
@@ -1,13 +1,14 @@
1
1
  /**
2
- * Stateless {@link AuthRequestStorage} for {@link HonoBff}.
2
+ * {@link AuthRequestStorageFactory} implementations for {@link HonoBff}.
3
3
  *
4
4
  * The pending authorization request — the `state`, its PKCE code verifier, and
5
5
  * where to return afterwards — has to survive the round trip to the identity
6
- * provider. The client's default {@link MemoryAuthRequestStorage} keeps it in
7
- * the process that started the flow, which breaks on any platform that spreads
8
- * requests across isolates or instances: `/auth/login` stores the record on one
9
- * and `/auth/callback` looks for it on another, so sign-in fails intermittently
10
- * with `unknown state parameter`.
6
+ * provider. {@link MemoryAuthRequestStorageFactory} keeps it in the process
7
+ * that started the flow, which breaks on any platform that spreads requests
8
+ * across isolates or instances: `/auth/login` stores the record on one and
9
+ * `/auth/callback` looks for it on another, so sign-in fails intermittently
10
+ * with `unknown state parameter`. `HonoBff` therefore has no default; the
11
+ * caller chooses.
11
12
  *
12
13
  * {@link EncryptedCookieAuthRequestStorage} carries the record in an encrypted
13
14
  * cookie instead (AES-GCM under a server-held secret, the same construction
@@ -18,7 +19,7 @@
18
19
  * **Which storage you pick is a reliability decision, not a security one.**
19
20
  * Server-side storage on its own says a `state` is pending somewhere — never
20
21
  * that *this* browser is the one that started it, so a process-wide store like
21
- * the memory default would otherwise let anyone's browser complete anyone
22
+ * {@link MemoryAuthRequestStorageFactory} would otherwise let anyone's browser complete anyone
22
23
  * else's pending sign-in (login CSRF). {@link HonoBff} closes that separately
23
24
  * and unconditionally: `/auth/login` also binds the `state` to the browser in
24
25
  * a short-lived cookie that `/auth/callback` requires, whichever storage is
@@ -27,7 +28,7 @@
27
28
  * @module
28
29
  */
29
30
  import type { Context } from "hono";
30
- import type { AuthRequestStorage } from "../../../client/mod.ts";
31
+ import { type AuthRequestStorage, type MemoryAuthRequestStorageOptions } from "../../../client/mod.ts";
31
32
  /**
32
33
  * Cookie attributes for the pending-authorization cookie.
33
34
  *
@@ -96,7 +97,11 @@ export interface EncryptedCookieAuthRequestStorageOptions {
96
97
  /**
97
98
  * How many pending requests the cookie carries at once. A second sign-in
98
99
  * started in another tab would otherwise evict the first, so its callback
99
- * would fail. Defaults to 3; the oldest is dropped beyond that.
100
+ * would fail. Defaults to 3; the oldest is dropped beyond that, and also
101
+ * whenever keeping it would push the sealed cookie past what every browser
102
+ * stores (about 4 KB), since a browser silently drops an oversized cookie
103
+ * and every pending sign-in in it with it. The sign-in being started is
104
+ * never the one dropped. Must be a positive integer.
100
105
  */
101
106
  maxPending?: number;
102
107
  }
@@ -106,8 +111,10 @@ export interface EncryptedCookieAuthRequestStorageOptions {
106
111
  * {@link HonoBffOptions.authRequestStorage} takes one of these rather than a
107
112
  * bare storage because a cookie-backed store can only read and write through
108
113
  * the request's {@link Context}. Pass an
109
- * {@link EncryptedCookieAuthRequestStorage}, or implement `forRequest` over
110
- * storage of your own.
114
+ * {@link EncryptedCookieAuthRequestStorage}, a
115
+ * {@link MemoryAuthRequestStorageFactory} for a single process, or implement
116
+ * `forRequest` over storage every instance shares. A storage that needs no
117
+ * request context is wrapped as `{ forRequest: () => storage }`.
111
118
  */
112
119
  export interface AuthRequestStorageFactory {
113
120
  /** Returns the storage bound to `c`, for the duration of that request. */
@@ -119,7 +126,7 @@ export interface AuthRequestStorageFactory {
119
126
  * `/auth/callback` need not be served by the same instance.
120
127
  *
121
128
  * Reach for it whenever the BFF runs on more than one isolate or instance —
122
- * the client's default in-memory storage fails those deployments with
129
+ * {@link MemoryAuthRequestStorageFactory} fails those deployments with
123
130
  * `unknown state parameter`. The AES key is derived on first use and shared by
124
131
  * every later request.
125
132
  *
@@ -146,6 +153,9 @@ export declare class EncryptedCookieAuthRequestStorage implements AuthRequestSto
146
153
  * carries a `__Host-`/`__Secure-` prefix the other cookie options contradict,
147
154
  * or when {@link AuthRequestCookieOptions.sameSite} is `"None"` without
148
155
  * {@link AuthRequestCookieOptions.secure}.
156
+ * @throws {RangeError} When
157
+ * {@link EncryptedCookieAuthRequestStorageOptions.maxPending} is not a
158
+ * positive integer.
149
159
  */
150
160
  constructor(options: EncryptedCookieAuthRequestStorageOptions);
151
161
  /** Returns the storage bound to `c`, for the duration of that request. */
@@ -166,3 +176,46 @@ export declare class EncryptedCookieAuthRequestStorage implements AuthRequestSto
166
176
  * ```
167
177
  */
168
178
  export declare function encryptedCookieAuthRequestStorage(options: EncryptedCookieAuthRequestStorageOptions): EncryptedCookieAuthRequestStorage;
179
+ /**
180
+ * In-process {@link AuthRequestStorageFactory}: every request shares one
181
+ * {@link MemoryAuthRequestStorage}, so a sign-in completes only on the process
182
+ * that started it.
183
+ *
184
+ * Pass it explicitly for a single long-lived process, tests and local
185
+ * development. A deployment that can serve `/auth/login` and `/auth/callback`
186
+ * from different instances or isolates fails sign-in with it, intermittently,
187
+ * with an unknown `state`; use {@link EncryptedCookieAuthRequestStorage} or a
188
+ * factory over shared storage there. The options are
189
+ * {@link MemoryAuthRequestStorage}'s: records older than `ttlMs` (10 minutes
190
+ * by default) are pruned and at most `maxEntries` (1000 by default) are held.
191
+ *
192
+ * @example
193
+ * ```ts
194
+ * import {
195
+ * HonoBff,
196
+ * MemoryAuthRequestStorageFactory,
197
+ * MemorySessionStore,
198
+ * } from "@udibo/oauth2/hono/bff";
199
+ * import { DirectClient } from "@udibo/oauth2/client";
200
+ *
201
+ * const bff = new HonoBff({
202
+ * client: new DirectClient({
203
+ * clientId: "local-app",
204
+ * clientSecret: "dev-secret",
205
+ * issuer: "http://localhost:8000",
206
+ * }),
207
+ * sessionStore: new MemorySessionStore(),
208
+ * authRequestStorage: new MemoryAuthRequestStorageFactory(),
209
+ * });
210
+ * ```
211
+ */
212
+ export declare class MemoryAuthRequestStorageFactory implements AuthRequestStorageFactory {
213
+ #private;
214
+ /**
215
+ * @throws {RangeError} When `maxEntries` is neither a positive integer nor
216
+ * `Infinity`.
217
+ */
218
+ constructor(options?: MemoryAuthRequestStorageOptions);
219
+ /** Returns the process-wide storage; `c` is not consulted. */
220
+ forRequest(_c: Context): AuthRequestStorage;
221
+ }
@@ -1,13 +1,14 @@
1
1
  /**
2
- * Stateless {@link AuthRequestStorage} for {@link HonoBff}.
2
+ * {@link AuthRequestStorageFactory} implementations for {@link HonoBff}.
3
3
  *
4
4
  * The pending authorization request — the `state`, its PKCE code verifier, and
5
5
  * where to return afterwards — has to survive the round trip to the identity
6
- * provider. The client's default {@link MemoryAuthRequestStorage} keeps it in
7
- * the process that started the flow, which breaks on any platform that spreads
8
- * requests across isolates or instances: `/auth/login` stores the record on one
9
- * and `/auth/callback` looks for it on another, so sign-in fails intermittently
10
- * with `unknown state parameter`.
6
+ * provider. {@link MemoryAuthRequestStorageFactory} keeps it in the process
7
+ * that started the flow, which breaks on any platform that spreads requests
8
+ * across isolates or instances: `/auth/login` stores the record on one and
9
+ * `/auth/callback` looks for it on another, so sign-in fails intermittently
10
+ * with `unknown state parameter`. `HonoBff` therefore has no default; the
11
+ * caller chooses.
11
12
  *
12
13
  * {@link EncryptedCookieAuthRequestStorage} carries the record in an encrypted
13
14
  * cookie instead (AES-GCM under a server-held secret, the same construction
@@ -18,7 +19,7 @@
18
19
  * **Which storage you pick is a reliability decision, not a security one.**
19
20
  * Server-side storage on its own says a `state` is pending somewhere — never
20
21
  * that *this* browser is the one that started it, so a process-wide store like
21
- * the memory default would otherwise let anyone's browser complete anyone
22
+ * {@link MemoryAuthRequestStorageFactory} would otherwise let anyone's browser complete anyone
22
23
  * else's pending sign-in (login CSRF). {@link HonoBff} closes that separately
23
24
  * and unconditionally: `/auth/login` also binds the `state` to the browser in
24
25
  * a short-lived cookie that `/auth/callback` requires, whichever storage is
@@ -27,11 +28,14 @@
27
28
  * @module
28
29
  */
29
30
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
31
+ import { MemoryAuthRequestStorage, } from "../../../client/mod.js";
30
32
  import { deriveAesKey, sealJson, unsealJson } from "../../../utils/crypto.js";
31
33
  import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
32
34
  const BASE_NAME = "oauth2_auth_request";
33
35
  const DEFAULT_TTL_MS = 10 * 60 * 1000;
34
36
  const DEFAULT_MAX_PENDING = 3;
37
+ // RFC 6265 §6.1 only guarantees 4096 bytes per cookie, attributes included.
38
+ const MAX_COOKIE_NAME_VALUE_BYTES = 3900;
35
39
  function cookieAttributes(options, maxAgeSeconds) {
36
40
  const { secure, path, domain } = resolvePrefixConstrainedAttributes(options);
37
41
  return {
@@ -49,7 +53,7 @@ function cookieAttributes(options, maxAgeSeconds) {
49
53
  * `/auth/callback` need not be served by the same instance.
50
54
  *
51
55
  * Reach for it whenever the BFF runs on more than one isolate or instance —
52
- * the client's default in-memory storage fails those deployments with
56
+ * {@link MemoryAuthRequestStorageFactory} fails those deployments with
53
57
  * `unknown state parameter`. The AES key is derived on first use and shared by
54
58
  * every later request.
55
59
  *
@@ -81,6 +85,9 @@ export class EncryptedCookieAuthRequestStorage {
81
85
  * carries a `__Host-`/`__Secure-` prefix the other cookie options contradict,
82
86
  * or when {@link AuthRequestCookieOptions.sameSite} is `"None"` without
83
87
  * {@link AuthRequestCookieOptions.secure}.
88
+ * @throws {RangeError} When
89
+ * {@link EncryptedCookieAuthRequestStorageOptions.maxPending} is not a
90
+ * positive integer.
84
91
  */
85
92
  constructor(options) {
86
93
  this.#cookie = options.cookie;
@@ -94,6 +101,10 @@ export class EncryptedCookieAuthRequestStorage {
94
101
  });
95
102
  this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
96
103
  this.#maxPending = options.maxPending ?? DEFAULT_MAX_PENDING;
104
+ if (!(Number.isInteger(this.#maxPending) && this.#maxPending > 0)) {
105
+ throw new RangeError("EncryptedCookieAuthRequestStorage maxPending must be a positive " +
106
+ `integer, got ${this.#maxPending}.`);
107
+ }
97
108
  }
98
109
  #key() {
99
110
  return (this.#keyPromise ??= deriveAesKey(this.#secret));
@@ -108,15 +119,23 @@ export class EncryptedCookieAuthRequestStorage {
108
119
  const cutoff = Date.now() - this.#ttlMs;
109
120
  return Object.fromEntries(Object.entries(pending).filter(([, record]) => record.createdAt > cutoff));
110
121
  }
111
- async #write(c, pending) {
122
+ async #write(c, pending, keep) {
112
123
  const entries = Object.entries(pending)
113
- .sort(([, a], [, b]) => b.createdAt - a.createdAt)
124
+ .sort(([stateA, a], [stateB, b]) => Number(stateB === keep) - Number(stateA === keep) ||
125
+ b.createdAt - a.createdAt)
114
126
  .slice(0, this.#maxPending);
115
127
  if (entries.length === 0) {
116
128
  this.#clear(c);
117
129
  return;
118
130
  }
119
- setCookie(c, this.#name, await sealJson(await this.#key(), Object.fromEntries(entries)), cookieAttributes(this.#cookie, Math.ceil(this.#ttlMs / 1000)));
131
+ const key = await this.#key();
132
+ let sealed = await sealJson(key, Object.fromEntries(entries));
133
+ while (entries.length > 1 &&
134
+ this.#name.length + 1 + sealed.length > MAX_COOKIE_NAME_VALUE_BYTES) {
135
+ entries.pop();
136
+ sealed = await sealJson(key, Object.fromEntries(entries));
137
+ }
138
+ setCookie(c, this.#name, sealed, cookieAttributes(this.#cookie, Math.ceil(this.#ttlMs / 1000)));
120
139
  }
121
140
  #clear(c) {
122
141
  deleteCookie(c, this.#name, cookieAttributes(this.#cookie));
@@ -128,7 +147,7 @@ export class EncryptedCookieAuthRequestStorage {
128
147
  set: async (state, value) => {
129
148
  const pending = await this.#read(c);
130
149
  pending[state] = value;
131
- await this.#write(c, pending);
150
+ await this.#write(c, pending, state);
132
151
  },
133
152
  delete: async (state) => {
134
153
  const pending = await this.#read(c);
@@ -156,3 +175,50 @@ export class EncryptedCookieAuthRequestStorage {
156
175
  export function encryptedCookieAuthRequestStorage(options) {
157
176
  return new EncryptedCookieAuthRequestStorage(options);
158
177
  }
178
+ /**
179
+ * In-process {@link AuthRequestStorageFactory}: every request shares one
180
+ * {@link MemoryAuthRequestStorage}, so a sign-in completes only on the process
181
+ * that started it.
182
+ *
183
+ * Pass it explicitly for a single long-lived process, tests and local
184
+ * development. A deployment that can serve `/auth/login` and `/auth/callback`
185
+ * from different instances or isolates fails sign-in with it, intermittently,
186
+ * with an unknown `state`; use {@link EncryptedCookieAuthRequestStorage} or a
187
+ * factory over shared storage there. The options are
188
+ * {@link MemoryAuthRequestStorage}'s: records older than `ttlMs` (10 minutes
189
+ * by default) are pruned and at most `maxEntries` (1000 by default) are held.
190
+ *
191
+ * @example
192
+ * ```ts
193
+ * import {
194
+ * HonoBff,
195
+ * MemoryAuthRequestStorageFactory,
196
+ * MemorySessionStore,
197
+ * } from "@udibo/oauth2/hono/bff";
198
+ * import { DirectClient } from "@udibo/oauth2/client";
199
+ *
200
+ * const bff = new HonoBff({
201
+ * client: new DirectClient({
202
+ * clientId: "local-app",
203
+ * clientSecret: "dev-secret",
204
+ * issuer: "http://localhost:8000",
205
+ * }),
206
+ * sessionStore: new MemorySessionStore(),
207
+ * authRequestStorage: new MemoryAuthRequestStorageFactory(),
208
+ * });
209
+ * ```
210
+ */
211
+ export class MemoryAuthRequestStorageFactory {
212
+ #storage;
213
+ /**
214
+ * @throws {RangeError} When `maxEntries` is neither a positive integer nor
215
+ * `Infinity`.
216
+ */
217
+ constructor(options = {}) {
218
+ this.#storage = new MemoryAuthRequestStorage(options);
219
+ }
220
+ /** Returns the process-wide storage; `c` is not consulted. */
221
+ forRequest(_c) {
222
+ return this.#storage;
223
+ }
224
+ }
@@ -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
@@ -308,10 +311,23 @@ export interface HonoBffOptions {
308
311
  */
309
312
  client: DirectClient;
310
313
  /**
311
- * Where session data lives. Defaults to {@link MemorySessionStore},
312
- * which is fine for dev and tests but must be replaced for production.
314
+ * Where session data lives. Required, because the right store depends on
315
+ * how many instances serve the app:
316
+ *
317
+ * - {@link EncryptedCookieSessionStore} keeps the session in an encrypted
318
+ * cookie, so every instance that shares its secret can read it and no
319
+ * server-side store is needed. It cannot list sessions, so it does not
320
+ * support {@link HonoBffOptions.backchannelLogout}, and sign-out cannot
321
+ * invalidate a copied cookie: it stays valid until its `maxAgeMs`. Use a
322
+ * shared {@link SessionStore} when you need revocation.
323
+ * - A {@link SessionStore} over a database or cache every instance shares,
324
+ * for server-side revocation and back-channel logout. Check it with
325
+ * `runSessionStoreContractTests`.
326
+ * - {@link MemorySessionStore} for a single process, tests and local
327
+ * development. A session it holds is unknown to every other instance, so
328
+ * behind a load balancer the user appears signed out.
313
329
  */
314
- sessionStore?: SessionStore;
330
+ sessionStore: SessionStore;
315
331
  /**
316
332
  * How long a BFF session is meant to live, in milliseconds. Defaults to 14
317
333
  * days ({@link DEFAULT_SESSION_MAX_AGE_MS}). Milliseconds match the store's
@@ -343,19 +359,25 @@ export interface HonoBffOptions {
343
359
  sessionMaxAgeMs?: number;
344
360
  /**
345
361
  * Where the pending authorization request (`state` → PKCE verifier,
346
- * return-to) lives, resolved per request. Defaults to the client's
347
- * configured `authRequestStorage`, which is in-memory unless the client was
348
- * given something else — fine for a single long-lived process, wrong for any
349
- * platform that can serve `/auth/login` and `/auth/callback` from different
350
- * isolates. Pass an {@link EncryptedCookieAuthRequestStorage} to carry the
351
- * record in an encrypted cookie instead, or implement
352
- * {@link AuthRequestStorageFactory} over storage of your own.
362
+ * return-to) lives, resolved per request. Required, because `/auth/login`
363
+ * and `/auth/callback` can be served by different instances or isolates.
364
+ * The BFF always uses this, never the client's own `authRequestStorage`.
365
+ *
366
+ * - {@link EncryptedCookieAuthRequestStorage} carries the record in an
367
+ * encrypted cookie, so any instance sharing its secret completes the
368
+ * sign-in with no server-side store.
369
+ * - An {@link AuthRequestStorageFactory} over storage every instance shares;
370
+ * wrap a context-free `AuthRequestStorage` as
371
+ * `{ forRequest: () => storage }`.
372
+ * - {@link MemoryAuthRequestStorageFactory} for a single process, tests and
373
+ * local development. A callback that reaches another instance fails with
374
+ * an unknown `state`.
353
375
  *
354
376
  * Whatever this resolves to, the pending request is **also** bound to the
355
377
  * browser that started it by the login-state cookie, which no option turns
356
378
  * off. See {@link loginStateTtlMs}.
357
379
  */
358
- authRequestStorage?: AuthRequestStorageFactory;
380
+ authRequestStorage: AuthRequestStorageFactory;
359
381
  /**
360
382
  * How long, in ms, the browser keeps the login-state cookie `/auth/login`
361
383
  * sets and `/auth/callback` requires — the `Max-Age` on that cookie, and so
@@ -425,10 +447,18 @@ export interface HonoBffOptions {
425
447
  * `/auth/logout` — after destroying the local session — additionally
426
448
  * redirects the browser to the OP's `end_session_endpoint` with
427
449
  * `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
450
+ * `post_logout_redirect_uri` built from the application's origin + the
429
451
  * open-redirect-guarded `return_to`. This ends the upstream IDP SSO session,
430
452
  * not just the local BFF session.
431
453
  *
454
+ * The application's origin is {@link resolveOrigin}'s, else the configured
455
+ * client `redirectUri`'s, else the request's — the last only when no
456
+ * `redirectUri` is configured or {@link deriveRedirectUri} is on, matching
457
+ * the origin sign-in sends. Behind a TLS-terminating proxy the request
458
+ * reports `http:`, so a deployment that derives its redirect URI there sets
459
+ * {@link resolveOrigin}; otherwise the URI would miss the registered
460
+ * `https:` one.
461
+ *
432
462
  * Defaults to `false` (local logout only): not every IDP supports it, and it
433
463
  * adds a redirect hop. When enabled but no end-session endpoint is known,
434
464
  * logout falls back to the plain local redirect and warns once via
@@ -481,8 +511,16 @@ export interface HonoBffOptions {
481
511
  * @example Pin every sign-in to one organization
482
512
  * ```ts
483
513
  * declare const client: DirectClient;
484
- *
485
- * const bff = new HonoBff({ client, extraParams: { organization: "acme" } });
514
+ * declare const stores: Pick<
515
+ * HonoBffOptions,
516
+ * "sessionStore" | "authRequestStorage"
517
+ * >;
518
+ *
519
+ * const bff = new HonoBff({
520
+ * client,
521
+ * ...stores,
522
+ * extraParams: { organization: "acme" },
523
+ * });
486
524
  * ```
487
525
  */
488
526
  extraParams?: Record<string, string>;
@@ -548,8 +586,16 @@ export interface HonoBffOptions {
548
586
  * @example Let a sign-in link choose the organization
549
587
  * ```ts
550
588
  * declare const client: DirectClient;
551
- *
552
- * const bff = new HonoBff({ client, forwardedParams: ["organization"] });
589
+ * declare const stores: Pick<
590
+ * HonoBffOptions,
591
+ * "sessionStore" | "authRequestStorage"
592
+ * >;
593
+ *
594
+ * const bff = new HonoBff({
595
+ * client,
596
+ * ...stores,
597
+ * forwardedParams: ["organization"],
598
+ * });
553
599
  * ```
554
600
  *
555
601
  * A link to `/auth/login?organization=acme&return_to=/dashboard` then
@@ -558,6 +604,14 @@ export interface HonoBffOptions {
558
604
  forwardedParams?: readonly string[];
559
605
  /** Default `return_to` applied when the browser doesn't pass one. */
560
606
  defaultReturnTo?: string;
607
+ /**
608
+ * Longest `return_to` accepted, in characters. `/auth/login` stores the
609
+ * value with the pending sign-in before any authentication, so a longer one
610
+ * is replaced by {@link defaultReturnTo} rather than kept — at login,
611
+ * callback, logout and in {@link HonoBff.loginContinuation}. Defaults to
612
+ * 2048; `Infinity` accepts any length.
613
+ */
614
+ maxReturnToLength?: number;
561
615
  /**
562
616
  * Optional hook run after the authorization code is exchanged. Lets
563
617
  * apps enrich the session with database-backed user info before the
@@ -622,9 +676,12 @@ export interface HonoBffOptions {
622
676
  export declare class HonoBff {
623
677
  #private;
624
678
  /**
625
- * Defaults are applied for the session store ({@linkcode MemorySessionStore}),
626
- * session mode (`"own"`), cookie name, and CSRF.
679
+ * Defaults are applied for session mode (`"own"`), cookie name, and CSRF.
680
+ * The session store and auth-request storage have no default.
627
681
  *
682
+ * @throws {TypeError} When {@linkcode HonoBffOptions.sessionStore} or
683
+ * {@linkcode HonoBffOptions.authRequestStorage} is missing, or when
684
+ * `authRequestStorage` has no `forRequest` method.
628
685
  * @throws {Error} When {@linkcode HonoBffOptions.backchannelLogout} is
629
686
  * configured against a store that cannot enumerate sessions (e.g.
630
687
  * {@linkcode EncryptedCookieSessionStore}), when an explicit
@@ -638,7 +695,9 @@ export declare class HonoBff {
638
695
  * {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
639
696
  * numeric `cookie.maxAge` is not positive or exceeds 400 days, when
640
697
  * {@linkcode HonoBffBackchannelOptions.maxBodyBytes} is not a positive
641
- * integer (a `RangeError`), or when
698
+ * integer (a `RangeError`), when
699
+ * {@linkcode HonoBffOptions.maxReturnToLength} is neither a positive
700
+ * integer nor `Infinity` (a `RangeError`), or when
642
701
  * {@linkcode HonoBffOptions.extraParams} or
643
702
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
644
703
  * parameter, gives one a name that is empty or not equal to its trimmed
@@ -56,9 +56,9 @@ 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
- import { DEFAULT_SESSION_MAX_AGE_MS, MemorySessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.js";
61
+ import { DEFAULT_SESSION_MAX_AGE_MS, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.js";
62
62
  import { buildDownstreamHeaders, buildUpstreamHeaders, isInvalidTokenChallenge, resolveForwardHeaders, resolveProxyUrl, } from "./proxy.js";
63
63
  function isRevokedGrant(error) {
64
64
  if (!isOAuth2Error(error))
@@ -268,6 +268,30 @@ function loginStateCookieOpts(opts, maxAgeSeconds) {
268
268
  ...(maxAgeSeconds === undefined ? {} : { maxAge: maxAgeSeconds }),
269
269
  };
270
270
  }
271
+ const MISSING_SESSION_STORE = "HonoBff requires options.sessionStore. Pass EncryptedCookieSessionStore " +
272
+ "to keep sessions in an encrypted cookie any instance can read (sign-out " +
273
+ "cannot revoke a copied cookie), a " +
274
+ "SessionStore over a database or cache every instance shares, or " +
275
+ "MemorySessionStore for a single process, tests and local development.";
276
+ const MISSING_AUTH_REQUEST_STORAGE = "HonoBff requires options.authRequestStorage. Pass " +
277
+ "EncryptedCookieAuthRequestStorage to keep pending sign-ins in an " +
278
+ "encrypted cookie any instance can read, an AuthRequestStorageFactory " +
279
+ "over storage every instance shares, or MemoryAuthRequestStorageFactory " +
280
+ "for a single process, tests and local development.";
281
+ function assertStoresConfigured(options) {
282
+ const missing = [
283
+ ...(options.sessionStore ? [] : [MISSING_SESSION_STORE]),
284
+ ...(options.authRequestStorage ? [] : [MISSING_AUTH_REQUEST_STORAGE]),
285
+ ];
286
+ if (missing.length > 0)
287
+ throw new TypeError(missing.join(" "));
288
+ if (typeof options.authRequestStorage?.forRequest !== "function") {
289
+ throw new TypeError("HonoBff options.authRequestStorage must be an " +
290
+ "AuthRequestStorageFactory, an object whose forRequest(c) returns the " +
291
+ "storage for that request. Wrap an AuthRequestStorage that needs no " +
292
+ "request context as { forRequest: () => storage }.");
293
+ }
294
+ }
271
295
  /**
272
296
  * Backend-for-Frontend adapter for Hono. Construct once per app, mount
273
297
  * `routes()` under `/auth`, and use `attachToken()` as middleware on
@@ -289,11 +313,15 @@ export class HonoBff {
289
313
  #sessionMode;
290
314
  #csrf;
291
315
  #extraAuthorizeParams;
316
+ #maxReturnToLength;
292
317
  #refreshes = new Map();
293
318
  /**
294
- * Defaults are applied for the session store ({@linkcode MemorySessionStore}),
295
- * session mode (`"own"`), cookie name, and CSRF.
319
+ * Defaults are applied for session mode (`"own"`), cookie name, and CSRF.
320
+ * The session store and auth-request storage have no default.
296
321
  *
322
+ * @throws {TypeError} When {@linkcode HonoBffOptions.sessionStore} or
323
+ * {@linkcode HonoBffOptions.authRequestStorage} is missing, or when
324
+ * `authRequestStorage` has no `forRequest` method.
297
325
  * @throws {Error} When {@linkcode HonoBffOptions.backchannelLogout} is
298
326
  * configured against a store that cannot enumerate sessions (e.g.
299
327
  * {@linkcode EncryptedCookieSessionStore}), when an explicit
@@ -307,7 +335,9 @@ export class HonoBff {
307
335
  * {@linkcode HonoBffOptions.sessionMaxAgeMs}), when `sessionMaxAgeMs` or a
308
336
  * numeric `cookie.maxAge` is not positive or exceeds 400 days, when
309
337
  * {@linkcode HonoBffBackchannelOptions.maxBodyBytes} is not a positive
310
- * integer (a `RangeError`), or when
338
+ * integer (a `RangeError`), when
339
+ * {@linkcode HonoBffOptions.maxReturnToLength} is neither a positive
340
+ * integer nor `Infinity` (a `RangeError`), or when
311
341
  * {@linkcode HonoBffOptions.extraParams} or
312
342
  * {@linkcode HonoBffOptions.forwardedParams} names a reserved authorize
313
343
  * parameter, gives one a name that is empty or not equal to its trimmed
@@ -317,9 +347,10 @@ export class HonoBff {
317
347
  * {@linkcode HonoBffOptions.scope} is also set.
318
348
  */
319
349
  constructor(options) {
350
+ assertStoresConfigured(options);
320
351
  this.#options = options;
321
352
  this.#client = options.client;
322
- this.#store = options.sessionStore ?? new MemorySessionStore();
353
+ this.#store = options.sessionStore;
323
354
  this.#paths = resolvePaths(options.paths, {
324
355
  backchannelMounted: options.backchannelLogout !== undefined,
325
356
  });
@@ -330,6 +361,13 @@ export class HonoBff {
330
361
  this.#sessionMode = options.sessionMode ?? "own";
331
362
  this.#csrf = resolveCsrf(options.csrf);
332
363
  this.#extraAuthorizeParams = resolveExtraAuthorizeParams(options);
364
+ this.#maxReturnToLength =
365
+ options.maxReturnToLength ?? DEFAULT_MAX_RETURN_TO_LENGTH;
366
+ if (this.#maxReturnToLength !== Infinity &&
367
+ !(Number.isInteger(this.#maxReturnToLength) && this.#maxReturnToLength > 0)) {
368
+ throw new RangeError("HonoBff maxReturnToLength must be a positive integer or Infinity, " +
369
+ `got ${this.#maxReturnToLength}.`);
370
+ }
333
371
  const sessionMaxAgeMs = positiveMs(options.sessionMaxAgeMs ?? DEFAULT_SESSION_MAX_AGE_MS, "sessionMaxAgeMs");
334
372
  this.#sessionMaxAgeMs = sessionMaxAgeMs;
335
373
  if (this.#sessionMode === "shared") {
@@ -437,6 +475,13 @@ export class HonoBff {
437
475
  deleteCookie(c, this.#cookieName, this.#cookieOptions());
438
476
  return null;
439
477
  }
478
+ #authRequestStorage(c) {
479
+ const storage = this.#options.authRequestStorage.forRequest(c);
480
+ if (!storage) {
481
+ throw new TypeError("HonoBff options.authRequestStorage.forRequest(c) returned no storage.");
482
+ }
483
+ return storage;
484
+ }
440
485
  #noStore(c) {
441
486
  c.header("Cache-Control", "no-store");
442
487
  c.header("Vary", "Cookie");
@@ -454,6 +499,20 @@ export class HonoBff {
454
499
  }
455
500
  setCookie(c, this.#loginStateCookieName, hashes.slice(-MAX_PENDING_LOGIN_STATES).join(LOGIN_STATE_SEPARATOR), loginStateCookieOpts(this.#options.cookie, Math.ceil(this.#loginStateTtlMs / 1000)));
456
501
  }
502
+ #safeReturnTo(value) {
503
+ return safeReturnTo(value, this.#options.defaultReturnTo ?? "/", {
504
+ maxLength: this.#maxReturnToLength,
505
+ });
506
+ }
507
+ #appOrigin(c) {
508
+ const resolved = this.#options.resolveOrigin?.(c);
509
+ if (resolved !== undefined)
510
+ return resolved;
511
+ const configured = this.#options.deriveRedirectUri
512
+ ? undefined
513
+ : this.#client.redirectUri;
514
+ return new URL(configured ?? c.req.url).origin;
515
+ }
457
516
  #derivedRedirectUri(c, origin) {
458
517
  if (!this.#options.deriveRedirectUri)
459
518
  return undefined;
@@ -473,12 +532,12 @@ export class HonoBff {
473
532
  const fetchSite = c.req.header("sec-fetch-site");
474
533
  if (fetchSite)
475
534
  return fetchSite === "same-origin" || fetchSite === "none";
476
- const target = new URL(c.req.url).origin;
535
+ const targets = [new URL(c.req.url).origin, this.#appOrigin(c)];
477
536
  const check = (val) => {
478
537
  if (!val)
479
538
  return undefined;
480
539
  try {
481
- return new URL(val).origin === target;
540
+ return targets.includes(new URL(val).origin);
482
541
  }
483
542
  catch {
484
543
  return false;
@@ -555,6 +614,7 @@ export class HonoBff {
555
614
  authorizeEndpoint: this.#client.authorizationEndpoint,
556
615
  loginPath: this.#paths.login,
557
616
  defaultReturnTo: this.#options.defaultReturnTo,
617
+ maxReturnToLength: this.#maxReturnToLength,
558
618
  });
559
619
  }
560
620
  #pathsRelative() {
@@ -583,7 +643,7 @@ export class HonoBff {
583
643
  loginHandler() {
584
644
  return async (c) => {
585
645
  this.#noStore(c);
586
- const returnTo = safeReturnTo(c.req.query("return_to"), this.#options.defaultReturnTo ?? "/");
646
+ const returnTo = this.#safeReturnTo(c.req.query("return_to"));
587
647
  const origin = this.#options.resolveOrigin?.(c);
588
648
  const requested = authorizeParamsForRequest(this.#extraAuthorizeParams, new URL(c.req.url).searchParams);
589
649
  const { url, state } = await this.#client.login({
@@ -593,7 +653,7 @@ export class HonoBff {
593
653
  prompt: requested.prompt,
594
654
  extraParams: requested.extraParams,
595
655
  redirectUri: this.#derivedRedirectUri(c, origin),
596
- authRequestStorage: this.#options.authRequestStorage?.forRequest(c),
656
+ authRequestStorage: this.#authRequestStorage(c),
597
657
  });
598
658
  this.#writeLoginStateHashes(c, [
599
659
  ...this.#loginStateHashes(c),
@@ -644,7 +704,7 @@ export class HonoBff {
644
704
  const { tokens, refreshToken, returnTo, raw } = await this.#client.exchangeAuthorizationCode(code, state, {
645
705
  origin,
646
706
  redirectUri: this.#derivedRedirectUri(c, origin),
647
- authRequestStorage: this.#options.authRequestStorage?.forRequest(c),
707
+ authRequestStorage: this.#authRequestStorage(c),
648
708
  });
649
709
  let user = null;
650
710
  let sid;
@@ -683,7 +743,7 @@ export class HonoBff {
683
743
  if (cookieValue !== existing) {
684
744
  setCookie(c, this.#cookieName, cookieValue, this.#cookieOptions());
685
745
  }
686
- return c.redirect(safeReturnTo(returnTo, this.#options.defaultReturnTo ?? "/"));
746
+ return c.redirect(this.#safeReturnTo(returnTo));
687
747
  }
688
748
  catch (cause) {
689
749
  return await fail({
@@ -723,11 +783,11 @@ export class HonoBff {
723
783
  await this.#store.destroy(cookieValue);
724
784
  deleteCookie(c, this.#cookieName, this.#cookieOptions());
725
785
  }
726
- const returnTo = safeReturnTo(c.req.query("return_to"), this.#options.defaultReturnTo ?? "/");
786
+ const returnTo = this.#safeReturnTo(c.req.query("return_to"));
727
787
  if (this.#options.rpInitiatedLogout) {
728
788
  const endSession = this.#client.endSessionEndpoint;
729
789
  if (endSession) {
730
- const origin = this.#options.resolveOrigin?.(c) ?? new URL(c.req.url).origin;
790
+ const origin = this.#appOrigin(c);
731
791
  const url = new URL(endSession, origin);
732
792
  if (idToken)
733
793
  url.searchParams.set("id_token_hint", idToken);
@@ -4,7 +4,12 @@
4
4
  * Exposes a {@link HonoBff} class that wraps an
5
5
  * `DirectClient` constructed with a client secret, plus the
6
6
  * {@link SessionStore} contract and two ready-to-use implementations
7
- * ({@link MemorySessionStore}, {@link EncryptedCookieSessionStore}).
7
+ * ({@link MemorySessionStore}, {@link EncryptedCookieSessionStore}), and the
8
+ * {@link AuthRequestStorageFactory} contract with two of its own
9
+ * ({@link MemoryAuthRequestStorageFactory},
10
+ * {@link EncryptedCookieAuthRequestStorage}). `HonoBff` requires one of each:
11
+ * the encrypted-cookie pair works across instances with no shared server, and
12
+ * the memory pair serves a single process, tests and local development.
8
13
  *
9
14
  * Need a server-side store (Postgres, Redis, …)? Implement the
10
15
  * {@link SessionStore} interface and verify it with
@@ -13,6 +18,6 @@
13
18
  * @module
14
19
  */
15
20
  export { type BackchannelLogoutSubject, type BffCallbackError, HonoBff, type HonoBffBackchannelOptions, type HonoBffCookieOptions, type HonoBffCsrfOptions, type HonoBffOptions, type HonoBffPaths, type HonoBffResourceServer, } from "./bff.ts";
16
- export { type AuthRequestCookieOptions, type AuthRequestStorageFactory, EncryptedCookieAuthRequestStorage, encryptedCookieAuthRequestStorage, type EncryptedCookieAuthRequestStorageOptions, } from "./auth-request-store.ts";
21
+ export { type AuthRequestCookieOptions, type AuthRequestStorageFactory, EncryptedCookieAuthRequestStorage, encryptedCookieAuthRequestStorage, type EncryptedCookieAuthRequestStorageOptions, MemoryAuthRequestStorageFactory, } from "./auth-request-store.ts";
17
22
  export { DEFAULT_PROXY_FORWARD_HEADERS, type HonoBffProxyOptions, } from "./proxy.ts";
18
23
  export { type BackchannelLogoutStore, type BoundedSessionStore, DEFAULT_SESSION_MAX_AGE_MS, EncryptedCookieSessionStore, type EncryptedCookieSessionStoreOptions, MemorySessionStore, type SessionData, type SessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.ts";
@@ -4,7 +4,12 @@
4
4
  * Exposes a {@link HonoBff} class that wraps an
5
5
  * `DirectClient` constructed with a client secret, plus the
6
6
  * {@link SessionStore} contract and two ready-to-use implementations
7
- * ({@link MemorySessionStore}, {@link EncryptedCookieSessionStore}).
7
+ * ({@link MemorySessionStore}, {@link EncryptedCookieSessionStore}), and the
8
+ * {@link AuthRequestStorageFactory} contract with two of its own
9
+ * ({@link MemoryAuthRequestStorageFactory},
10
+ * {@link EncryptedCookieAuthRequestStorage}). `HonoBff` requires one of each:
11
+ * the encrypted-cookie pair works across instances with no shared server, and
12
+ * the memory pair serves a single process, tests and local development.
8
13
  *
9
14
  * Need a server-side store (Postgres, Redis, …)? Implement the
10
15
  * {@link SessionStore} interface and verify it with
@@ -13,6 +18,6 @@
13
18
  * @module
14
19
  */
15
20
  export { HonoBff, } from "./bff.js";
16
- export { EncryptedCookieAuthRequestStorage, encryptedCookieAuthRequestStorage, } from "./auth-request-store.js";
21
+ export { EncryptedCookieAuthRequestStorage, encryptedCookieAuthRequestStorage, MemoryAuthRequestStorageFactory, } from "./auth-request-store.js";
17
22
  export { DEFAULT_PROXY_FORWARD_HEADERS, } from "./proxy.js";
18
23
  export { DEFAULT_SESSION_MAX_AGE_MS, EncryptedCookieSessionStore, MemorySessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.js";
@@ -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;
@@ -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.2",
3
+ "version": "0.17.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",