@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.
- package/dist/adapters/hono/bff/auth-request-store.d.ts +65 -12
- package/dist/adapters/hono/bff/auth-request-store.js +78 -12
- package/dist/adapters/hono/bff/bff.d.ts +78 -19
- package/dist/adapters/hono/bff/bff.js +74 -14
- package/dist/adapters/hono/bff/mod.d.ts +7 -2
- package/dist/adapters/hono/bff/mod.js +7 -2
- package/dist/client/direct-client.d.ts +9 -0
- package/dist/client/direct-client.js +11 -0
- package/dist/client/storage.d.ts +24 -5
- package/dist/client/storage.js +32 -5
- package/dist/utils/url.d.ts +23 -1
- package/dist/utils/url.js +15 -2
- package/package.json +1 -1
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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},
|
|
110
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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]) =>
|
|
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
|
-
|
|
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.
|
|
312
|
-
*
|
|
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
|
|
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.
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
626
|
-
* session
|
|
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`),
|
|
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,
|
|
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
|
|
295
|
-
* session
|
|
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`),
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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.#
|
|
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.#
|
|
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
|
|
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")
|
|
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.#
|
|
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();
|
package/dist/client/storage.d.ts
CHANGED
|
@@ -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},
|
|
143
|
-
*
|
|
144
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
*/
|
package/dist/client/storage.js
CHANGED
|
@@ -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},
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
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
|
|
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;
|
package/dist/utils/url.d.ts
CHANGED
|
@@ -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.
|
|
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",
|