@udibo/oauth2 0.16.0 → 0.17.1

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
  *
@@ -110,8 +111,10 @@ export interface EncryptedCookieAuthRequestStorageOptions {
110
111
  * {@link HonoBffOptions.authRequestStorage} takes one of these rather than a
111
112
  * bare storage because a cookie-backed store can only read and write through
112
113
  * the request's {@link Context}. Pass an
113
- * {@link EncryptedCookieAuthRequestStorage}, or implement `forRequest` over
114
- * 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 }`.
115
118
  */
116
119
  export interface AuthRequestStorageFactory {
117
120
  /** Returns the storage bound to `c`, for the duration of that request. */
@@ -123,7 +126,7 @@ export interface AuthRequestStorageFactory {
123
126
  * `/auth/callback` need not be served by the same instance.
124
127
  *
125
128
  * Reach for it whenever the BFF runs on more than one isolate or instance —
126
- * the client's default in-memory storage fails those deployments with
129
+ * {@link MemoryAuthRequestStorageFactory} fails those deployments with
127
130
  * `unknown state parameter`. The AES key is derived on first use and shared by
128
131
  * every later request.
129
132
  *
@@ -173,3 +176,46 @@ export declare class EncryptedCookieAuthRequestStorage implements AuthRequestSto
173
176
  * ```
174
177
  */
175
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,6 +28,7 @@
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";
@@ -51,7 +53,7 @@ function cookieAttributes(options, maxAgeSeconds) {
51
53
  * `/auth/callback` need not be served by the same instance.
52
54
  *
53
55
  * Reach for it whenever the BFF runs on more than one isolate or instance —
54
- * the client's default in-memory storage fails those deployments with
56
+ * {@link MemoryAuthRequestStorageFactory} fails those deployments with
55
57
  * `unknown state parameter`. The AES key is derived on first use and shared by
56
58
  * every later request.
57
59
  *
@@ -173,3 +175,50 @@ export class EncryptedCookieAuthRequestStorage {
173
175
  export function encryptedCookieAuthRequestStorage(options) {
174
176
  return new EncryptedCookieAuthRequestStorage(options);
175
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
+ }
@@ -311,10 +311,23 @@ export interface HonoBffOptions {
311
311
  */
312
312
  client: DirectClient;
313
313
  /**
314
- * Where session data lives. Defaults to {@link MemorySessionStore},
315
- * which is fine for dev and tests but must be replaced for production.
316
- */
317
- sessionStore?: SessionStore;
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.
329
+ */
330
+ sessionStore: SessionStore;
318
331
  /**
319
332
  * How long a BFF session is meant to live, in milliseconds. Defaults to 14
320
333
  * days ({@link DEFAULT_SESSION_MAX_AGE_MS}). Milliseconds match the store's
@@ -346,19 +359,25 @@ export interface HonoBffOptions {
346
359
  sessionMaxAgeMs?: number;
347
360
  /**
348
361
  * Where the pending authorization request (`state` → PKCE verifier,
349
- * return-to) lives, resolved per request. Defaults to the client's
350
- * configured `authRequestStorage`, which is in-memory unless the client was
351
- * given something else — fine for a single long-lived process, wrong for any
352
- * platform that can serve `/auth/login` and `/auth/callback` from different
353
- * isolates. Pass an {@link EncryptedCookieAuthRequestStorage} to carry the
354
- * record in an encrypted cookie instead, or implement
355
- * {@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`.
356
375
  *
357
376
  * Whatever this resolves to, the pending request is **also** bound to the
358
377
  * browser that started it by the login-state cookie, which no option turns
359
378
  * off. See {@link loginStateTtlMs}.
360
379
  */
361
- authRequestStorage?: AuthRequestStorageFactory;
380
+ authRequestStorage: AuthRequestStorageFactory;
362
381
  /**
363
382
  * How long, in ms, the browser keeps the login-state cookie `/auth/login`
364
383
  * sets and `/auth/callback` requires — the `Max-Age` on that cookie, and so
@@ -492,8 +511,16 @@ export interface HonoBffOptions {
492
511
  * @example Pin every sign-in to one organization
493
512
  * ```ts
494
513
  * declare const client: DirectClient;
495
- *
496
- * 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
+ * });
497
524
  * ```
498
525
  */
499
526
  extraParams?: Record<string, string>;
@@ -559,8 +586,16 @@ export interface HonoBffOptions {
559
586
  * @example Let a sign-in link choose the organization
560
587
  * ```ts
561
588
  * declare const client: DirectClient;
562
- *
563
- * 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
+ * });
564
599
  * ```
565
600
  *
566
601
  * A link to `/auth/login?organization=acme&return_to=/dashboard` then
@@ -641,9 +676,12 @@ export interface HonoBffOptions {
641
676
  export declare class HonoBff {
642
677
  #private;
643
678
  /**
644
- * Defaults are applied for the session store ({@linkcode MemorySessionStore}),
645
- * 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.
646
681
  *
682
+ * @throws {TypeError} When {@linkcode HonoBffOptions.sessionStore} or
683
+ * {@linkcode HonoBffOptions.authRequestStorage} is missing, or when
684
+ * `authRequestStorage` has no `forRequest` method.
647
685
  * @throws {Error} When {@linkcode HonoBffOptions.backchannelLogout} is
648
686
  * configured against a store that cannot enumerate sessions (e.g.
649
687
  * {@linkcode EncryptedCookieSessionStore}), when an explicit
@@ -58,7 +58,7 @@ import { sha256Hash } from "../../../server/utils/hash.js";
58
58
  import { OAUTH2_CONTEXT_KEY } from "../_common.js";
59
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
@@ -292,9 +316,12 @@ export class HonoBff {
292
316
  #maxReturnToLength;
293
317
  #refreshes = new Map();
294
318
  /**
295
- * Defaults are applied for the session store ({@linkcode MemorySessionStore}),
296
- * 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.
297
321
  *
322
+ * @throws {TypeError} When {@linkcode HonoBffOptions.sessionStore} or
323
+ * {@linkcode HonoBffOptions.authRequestStorage} is missing, or when
324
+ * `authRequestStorage` has no `forRequest` method.
298
325
  * @throws {Error} When {@linkcode HonoBffOptions.backchannelLogout} is
299
326
  * configured against a store that cannot enumerate sessions (e.g.
300
327
  * {@linkcode EncryptedCookieSessionStore}), when an explicit
@@ -320,9 +347,10 @@ export class HonoBff {
320
347
  * {@linkcode HonoBffOptions.scope} is also set.
321
348
  */
322
349
  constructor(options) {
350
+ assertStoresConfigured(options);
323
351
  this.#options = options;
324
352
  this.#client = options.client;
325
- this.#store = options.sessionStore ?? new MemorySessionStore();
353
+ this.#store = options.sessionStore;
326
354
  this.#paths = resolvePaths(options.paths, {
327
355
  backchannelMounted: options.backchannelLogout !== undefined,
328
356
  });
@@ -447,6 +475,13 @@ export class HonoBff {
447
475
  deleteCookie(c, this.#cookieName, this.#cookieOptions());
448
476
  return null;
449
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
+ }
450
485
  #noStore(c) {
451
486
  c.header("Cache-Control", "no-store");
452
487
  c.header("Vary", "Cookie");
@@ -618,7 +653,7 @@ export class HonoBff {
618
653
  prompt: requested.prompt,
619
654
  extraParams: requested.extraParams,
620
655
  redirectUri: this.#derivedRedirectUri(c, origin),
621
- authRequestStorage: this.#options.authRequestStorage?.forRequest(c),
656
+ authRequestStorage: this.#authRequestStorage(c),
622
657
  });
623
658
  this.#writeLoginStateHashes(c, [
624
659
  ...this.#loginStateHashes(c),
@@ -669,7 +704,7 @@ export class HonoBff {
669
704
  const { tokens, refreshToken, returnTo, raw } = await this.#client.exchangeAuthorizationCode(code, state, {
670
705
  origin,
671
706
  redirectUri: this.#derivedRedirectUri(c, origin),
672
- authRequestStorage: this.#options.authRequestStorage?.forRequest(c),
707
+ authRequestStorage: this.#authRequestStorage(c),
673
708
  });
674
709
  let user = null;
675
710
  let sid;
@@ -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";
@@ -117,8 +117,9 @@ export declare function exportSigningKeyJwk(key: SigningKey): Promise<JsonWebKey
117
117
  }>;
118
118
  /**
119
119
  * Import a signing key previously exported with {@link exportSigningKeyJwk}.
120
- * A JWK without a `kid` is given a random one, so every instance importing it
121
- * would publish and stamp a different `kid` — persist the exported `kid`.
120
+ * A JWK that sets a `kid` keeps it. One without a `kid` gets its RFC 7638 JWK
121
+ * thumbprint (base64url SHA-256 of the public key), so every instance
122
+ * importing the same key publishes and stamps the same `kid`.
122
123
  */
123
124
  export declare function importSigningKeyJwk(jwk: JsonWebKey & {
124
125
  kid?: string;
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { base64urlDecode, base64urlEncode } from "../utils/crypto.js";
15
15
  import { snapshotAuthenticationContext } from "../utils/authentication-context.js";
16
+ import { jwkThumbprint } from "../utils/_jwk-thumbprint.js";
16
17
  /** A {@link SigningKeyProvider} over one fixed key. */
17
18
  export class StaticSigningKeyProvider {
18
19
  #key;
@@ -130,12 +131,13 @@ export async function exportSigningKeyJwk(key) {
130
131
  }
131
132
  /**
132
133
  * Import a signing key previously exported with {@link exportSigningKeyJwk}.
133
- * A JWK without a `kid` is given a random one, so every instance importing it
134
- * would publish and stamp a different `kid` — persist the exported `kid`.
134
+ * A JWK that sets a `kid` keeps it. One without a `kid` gets its RFC 7638 JWK
135
+ * thumbprint (base64url SHA-256 of the public key), so every instance
136
+ * importing the same key publishes and stamps the same `kid`.
135
137
  */
136
138
  export async function importSigningKeyJwk(jwk) {
137
139
  const privateKey = await crypto.subtle.importKey("jwk", jwk, ES256_PARAMS, true, ["sign"]);
138
- const kid = jwk.kid ?? crypto.randomUUID();
140
+ const kid = jwk.kid ?? (await jwkThumbprint(jwk));
139
141
  return {
140
142
  kid,
141
143
  alg: "ES256",
@@ -0,0 +1,12 @@
1
+ /**
2
+ * RFC 7638 JWK thumbprints.
3
+ *
4
+ * @module
5
+ */
6
+ /**
7
+ * The RFC 7638 SHA-256 thumbprint of a JWK, base64url-encoded. Only the key
8
+ * type's required public members are hashed, so a private JWK and its public
9
+ * half share a thumbprint. Rejects with a `TypeError` for a key type other
10
+ * than `EC`, `OKP` or `RSA`, or when a required member is missing.
11
+ */
12
+ export declare function jwkThumbprint(jwk: JsonWebKey): Promise<string>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * RFC 7638 JWK thumbprints.
3
+ *
4
+ * @module
5
+ */
6
+ import { encodeBase64Url } from "./_encoding.js";
7
+ const REQUIRED_MEMBERS = {
8
+ EC: ["crv", "kty", "x", "y"],
9
+ OKP: ["crv", "kty", "x"],
10
+ RSA: ["e", "kty", "n"],
11
+ };
12
+ /**
13
+ * The RFC 7638 SHA-256 thumbprint of a JWK, base64url-encoded. Only the key
14
+ * type's required public members are hashed, so a private JWK and its public
15
+ * half share a thumbprint. Rejects with a `TypeError` for a key type other
16
+ * than `EC`, `OKP` or `RSA`, or when a required member is missing.
17
+ */
18
+ export async function jwkThumbprint(jwk) {
19
+ const members = jwk.kty === undefined ? undefined : REQUIRED_MEMBERS[jwk.kty];
20
+ if (!members) {
21
+ throw new TypeError(`Cannot compute a JWK thumbprint for key type ${JSON.stringify(jwk.kty)}`);
22
+ }
23
+ const fields = jwk;
24
+ const canonical = {};
25
+ for (const member of members) {
26
+ const value = fields[member];
27
+ if (typeof value !== "string") {
28
+ throw new TypeError(`Cannot compute a JWK thumbprint without the "${member}" member`);
29
+ }
30
+ canonical[member] = value;
31
+ }
32
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(canonical)));
33
+ return encodeBase64Url(new Uint8Array(digest));
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
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",