@udibo/oauth2 0.23.0 → 0.24.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.
@@ -327,7 +327,9 @@ export interface HonoBffOptions {
327
327
  * - A {@link SessionStore} over a database or cache every instance shares,
328
328
  * for server-side revocation and back-channel logout. Implement
329
329
  * `RefreshLockStore` (`withRefreshLock`) on it so instances refresh a
330
- * session one at a time, and check it with `runSessionStoreContractTests`.
330
+ * session one at a time, pass each refresh a `RefreshLease` so a holder
331
+ * whose lock expired cannot overwrite or end the session, and check it
332
+ * with `runSessionStoreContractTests`.
331
333
  * - {@link MemorySessionStore} for a single process, tests and local
332
334
  * development. A session it holds is unknown to every other instance, so
333
335
  * behind a load balancer the user appears signed out.
@@ -681,7 +683,7 @@ export interface HonoBffOptions {
681
683
  /**
682
684
  * Resolves the per-request origin (scheme + host) used to build the
683
685
  * browser-facing authorize URL and `redirect_uri`. Lets ONE build-once BFF
684
- * serve every deployment host (preview URLs, multi-tenant subdomains): the
686
+ * serve every deployment host (preview URLs, per-application subdomains): the
685
687
  * configured client's authorize/redirect paths are retargeted at this
686
688
  * origin per request. Return only allowlisted/trusted origins (never a raw
687
689
  * forged `Host`). When omitted, the client's configured origin is used.
@@ -1080,7 +1080,7 @@ export class HonoBff {
1080
1080
  return await this.#exchangeRefresh(entry);
1081
1081
  const { cookieValue } = entry;
1082
1082
  try {
1083
- return await store.withRefreshLock(cookieValue, async () => {
1083
+ return await store.withRefreshLock(cookieValue, async (lease) => {
1084
1084
  const current = await store.read(cookieValue);
1085
1085
  if (!current)
1086
1086
  return { status: "revoked" };
@@ -1090,28 +1090,31 @@ export class HonoBff {
1090
1090
  entry: { cookieValue, data: current },
1091
1091
  };
1092
1092
  }
1093
- return await this.#exchangeRefresh({ cookieValue, data: current });
1093
+ return await this.#exchangeRefresh({ cookieValue, data: current }, lease);
1094
1094
  });
1095
1095
  }
1096
1096
  catch {
1097
1097
  return { status: "unavailable" };
1098
1098
  }
1099
1099
  }
1100
- async #exchangeRefresh(entry) {
1100
+ async #exchangeRefresh(entry, lease) {
1101
1101
  const { cookieValue, data } = entry;
1102
- if (!data.refreshToken)
1102
+ const refreshToken = data.refreshToken;
1103
+ if (!refreshToken)
1103
1104
  return { status: "unavailable" };
1104
1105
  try {
1105
- const result = await this.#client.exchangeRefreshToken(data.refreshToken);
1106
+ const result = await this.#client.exchangeRefreshToken(refreshToken);
1106
1107
  const refreshed = {
1107
1108
  tokens: result.tokens,
1108
- refreshToken: result.refreshToken ?? data.refreshToken,
1109
+ refreshToken: result.refreshToken ?? refreshToken,
1109
1110
  user: data.user,
1110
1111
  sid: data.sid,
1111
1112
  createdAt: data.createdAt,
1112
1113
  updatedAt: Date.now(),
1113
1114
  };
1114
- const newCookieValue = await this.#store.update(cookieValue, refreshed);
1115
+ const newCookieValue = lease
1116
+ ? await lease.update(refreshed)
1117
+ : await this.#store.update(cookieValue, refreshed);
1115
1118
  return {
1116
1119
  status: "refreshed",
1117
1120
  entry: { cookieValue: newCookieValue, data: refreshed },
@@ -1121,12 +1124,28 @@ export class HonoBff {
1121
1124
  if (!isRevokedGrant(error))
1122
1125
  return { status: "unavailable" };
1123
1126
  const current = await this.#store.read(cookieValue);
1124
- if (current && current.refreshToken !== data.refreshToken) {
1127
+ if (current && current.refreshToken !== refreshToken) {
1125
1128
  return { status: "refreshed", entry: { cookieValue, data: current } };
1126
1129
  }
1127
- await this.#destroySession(cookieValue);
1130
+ if (!lease) {
1131
+ await this.#destroySession(cookieValue);
1132
+ return { status: "revoked" };
1133
+ }
1134
+ if (current && !(await lease.destroy(refreshToken))) {
1135
+ return await this.#afterRefusedDestroy(cookieValue, refreshToken);
1136
+ }
1137
+ await this.#rotatedCookies?.forget(cookieValue);
1138
+ return { status: "revoked" };
1139
+ }
1140
+ }
1141
+ async #afterRefusedDestroy(cookieValue, refreshToken) {
1142
+ const current = await this.#store.read(cookieValue);
1143
+ if (!current)
1128
1144
  return { status: "revoked" };
1145
+ if (current.refreshToken !== refreshToken) {
1146
+ return { status: "refreshed", entry: { cookieValue, data: current } };
1129
1147
  }
1148
+ return { status: "unavailable" };
1130
1149
  }
1131
1150
  /**
1132
1151
  * Middleware that reads the session cookie, refreshes the access token
@@ -21,4 +21,4 @@
21
21
  export { type BackchannelLogoutSubject, type BffCallbackError, HonoBff, type HonoBffBackchannelOptions, type HonoBffCookieOptions, type HonoBffCsrfOptions, type HonoBffOptions, type HonoBffPaths, type HonoBffResourceServer, } from "./bff.ts";
22
22
  export { type AuthRequestCookieOptions, type AuthRequestStorageFactory, EncryptedCookieAuthRequestStorage, encryptedCookieAuthRequestStorage, type EncryptedCookieAuthRequestStorageOptions, MemoryAuthRequestStorageFactory, } from "./auth-request-store.ts";
23
23
  export { DEFAULT_PROXY_FORWARD_HEADERS, type HonoBffProxyOptions, } from "./proxy.ts";
24
- export { type BackchannelLogoutStore, type BoundedSessionStore, DEFAULT_SESSION_MAX_AGE_MS, EncryptedCookieSessionStore, type EncryptedCookieSessionStoreOptions, MemorySessionStore, type RefreshLockStore, type SessionData, type SessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.ts";
24
+ export { type BackchannelLogoutStore, type BoundedSessionStore, DEFAULT_SESSION_MAX_AGE_MS, EncryptedCookieSessionStore, type EncryptedCookieSessionStoreOptions, MemorySessionStore, type MemorySessionStoreOptions, type RefreshLease, type RefreshLockStore, type SessionData, type SessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.ts";
@@ -101,19 +101,59 @@ export interface RefreshLockStore {
101
101
  * seconds when its discovery cache is cold, since discovery tries two
102
102
  * well-known paths and every request has a 10-second deadline. If the
103
103
  * lock expires when its holder dies, expire it only after that plus the
104
- * store's own I/O, or renew the lease while it is held. A lock that
105
- * expires mid-refresh lets a second instance spend the same token.
104
+ * store's own I/O, or renew it while `refresh` runs.
105
+ * - Pass `refresh` a {@link RefreshLease} for this call. The parameter is
106
+ * optional only so stores written before it keep compiling. `HonoBff` writes
107
+ * and destroys the session through it, so a holder that lost the lock
108
+ * (it stalled past the expiry and another instance took it) can no
109
+ * longer overwrite or end the session that instance refreshed. A store
110
+ * that calls `refresh()` with no lease still works, but a lock that
111
+ * expires mid-refresh then lets the former holder's write or sign-out
112
+ * land on top of the new holder's.
106
113
  * - Reject when the lock cannot be acquired within a bound you choose.
107
114
  * `HonoBff` then answers `502` `temporarily_unavailable` and keeps the
108
115
  * session.
109
116
  *
110
- * A Postgres store can hold `pg_advisory_xact_lock` on a hash of the session
111
- * key in a transaction around `refresh`, with a `lock_timeout`. A Redis
112
- * store can take a key with `SET NX PX` and wait for it with a deadline. Key
113
- * the lock by the same digest the store keys its records by, never the raw
114
- * cookie value, and include the tenant in a tenant-scoped store.
117
+ * Keep the lock beside the session record, so the lease's conditional
118
+ * write is one atomic compare-and-set on that record: for example a lease
119
+ * token and a lease expiry on the session row, taken by an update that
120
+ * matches only when no unexpired lease is set, and checked by the lease's
121
+ * `update` and `destroy`. Never hold a database transaction open across
122
+ * `refresh`: it spans a network call to the authorization server. Key the
123
+ * lock by the same key the store keys its records by — the digest, never
124
+ * the raw cookie value, plus any prefix or scope the store adds to it.
115
125
  */
116
- withRefreshLock<T>(cookieValue: string, refresh: () => Promise<T>): Promise<T>;
126
+ withRefreshLock<T>(cookieValue: string, refresh: (lease?: RefreshLease) => Promise<T>): Promise<T>;
127
+ }
128
+ /**
129
+ * The fence a {@link RefreshLockStore} passes to the refresh it runs: an
130
+ * `update` and a `destroy` of the locked session that take effect only while
131
+ * this call still holds the lock. The lease is current from when the call
132
+ * acquires the lock until the call releases the lock or another call takes
133
+ * it; a store may also end it when the lock expires. Once another call has
134
+ * taken the lock, the lease is never current again.
135
+ */
136
+ export interface RefreshLease {
137
+ /**
138
+ * Writes the locked session like {@link SessionStore.update}, as one atomic
139
+ * step that also checks this lease is current. Resolves with the cookie
140
+ * value to set.
141
+ *
142
+ * @throws {InvalidGrantError} When the lease is no longer current, or the
143
+ * session is missing, expired or revoked. It never creates or restores a
144
+ * session.
145
+ */
146
+ update(data: SessionData): Promise<string>;
147
+ /**
148
+ * Destroys the locked session, as one atomic step, only while this lease
149
+ * is current and the session still stores `refreshToken`. `HonoBff` calls
150
+ * it after the authorization server refuses that refresh token.
151
+ *
152
+ * @returns `true` when it destroyed the session; `false` when it destroyed
153
+ * nothing, because the lease is no longer current, the session stores a
154
+ * different refresh token, or the session no longer exists.
155
+ */
156
+ destroy(refreshToken: string): Promise<boolean>;
117
157
  }
118
158
  /** Whether a store implements the {@link RefreshLockStore} capability. */
119
159
  export declare function supportsRefreshLock(store: SessionStore): store is SessionStore & RefreshLockStore;
@@ -172,6 +212,30 @@ export interface SessionStore {
172
212
  /** Destroy the session identified by `cookieValue`. */
173
213
  destroy(cookieValue: string): Promise<void>;
174
214
  }
215
+ /** Options for {@link MemorySessionStore}. */
216
+ export interface MemorySessionStoreOptions {
217
+ /**
218
+ * How long a `withRefreshLock` call holds the lock without renewing it, in
219
+ * milliseconds. The store renews it every third of this while the refresh
220
+ * runs. A lease that still lapses, because its holder's timers were
221
+ * starved, is lost at once: another call may take the lock, and the lost
222
+ * lease's `update` and `destroy` are refused.
223
+ *
224
+ * Defaults to no expiry, which is right for one process: a holder cannot
225
+ * die without the store dying with it. Set it in tests that model an
226
+ * instance losing its lock mid-refresh. Must be a positive number no
227
+ * larger than 2147483647 (2^31 − 1, the longest timer delay).
228
+ */
229
+ refreshLeaseMs?: number;
230
+ /**
231
+ * How long `withRefreshLock` waits for a lock another call holds, in
232
+ * milliseconds, before it rejects. `HonoBff` then answers `502` and keeps
233
+ * the session. Defaults to waiting until the lock is released or expires.
234
+ * Must be a positive number no larger than 2147483647 (2^31 − 1, the
235
+ * longest timer delay).
236
+ */
237
+ refreshLockWaitMs?: number;
238
+ }
175
239
  /**
176
240
  * In-memory stateful session store. Data is lost when the process
177
241
  * restarts. Suitable for single-process development or tests; use a
@@ -179,6 +243,11 @@ export interface SessionStore {
179
243
  */
180
244
  export declare class MemorySessionStore implements SessionStore, BackchannelLogoutStore, RefreshLockStore {
181
245
  #private;
246
+ /**
247
+ * @throws {RangeError} When `refreshLeaseMs` or `refreshLockWaitMs` is not
248
+ * a positive number of at most 2^31 − 1 milliseconds.
249
+ */
250
+ constructor(options?: MemorySessionStoreOptions);
182
251
  /** Stores the session under a fresh random id and returns it as the cookie value. */
183
252
  create(data: SessionData): Promise<string>;
184
253
  /** Looks up the session by its id, or `null` if no live session matches. */
@@ -188,11 +257,15 @@ export declare class MemorySessionStore implements SessionStore, BackchannelLogo
188
257
  /** Removes the session record for the given id. */
189
258
  destroy(cookieValue: string): Promise<void>;
190
259
  /**
191
- * Runs `refresh` once every earlier call for the same session has settled,
192
- * so every `HonoBff` sharing this store object refreshes a session once.
193
- * Calls for other sessions run alongside.
260
+ * Runs `refresh` once no other call holds the session's lock, so every
261
+ * `HonoBff` sharing this store object refreshes a session once. Calls for
262
+ * other sessions run alongside. `refresh` gets a {@link RefreshLease} that
263
+ * writes and destroys the session only while this call holds the lock.
264
+ *
265
+ * @throws {Error} When the lock stays held past
266
+ * {@linkcode MemorySessionStoreOptions.refreshLockWaitMs}.
194
267
  */
195
- withRefreshLock<T>(cookieValue: string, refresh: () => Promise<T>): Promise<T>;
268
+ withRefreshLock<T>(cookieValue: string, refresh: (lease: RefreshLease) => Promise<T>): Promise<T>;
196
269
  /**
197
270
  * Destroys the sessions a validated logout_token targets: the specific `sid`
198
271
  * when given, otherwise every session for `sub`. Returns the number
@@ -55,6 +55,16 @@ export function sessionStoreMaxAgeMs(store) {
55
55
  ? maxAgeMs
56
56
  : undefined;
57
57
  }
58
+ const MAX_TIMER_MS = 2 ** 31 - 1;
59
+ function timerMs(name, value) {
60
+ if (value === undefined)
61
+ return Infinity;
62
+ if (!(value > 0 && value <= MAX_TIMER_MS)) {
63
+ throw new RangeError(`${name} must be a positive number of milliseconds no larger than ` +
64
+ `${MAX_TIMER_MS}, got ${value}. Leave it unset for no limit.`);
65
+ }
66
+ return value;
67
+ }
58
68
  /**
59
69
  * In-memory stateful session store. Data is lost when the process
60
70
  * restarts. Suitable for single-process development or tests; use a
@@ -62,7 +72,18 @@ export function sessionStoreMaxAgeMs(store) {
62
72
  */
63
73
  export class MemorySessionStore {
64
74
  #sessions = new Map();
65
- #refreshLocks = new Map();
75
+ #leases = new Map();
76
+ #lockWaiters = new Map();
77
+ #leaseMs;
78
+ #lockWaitMs;
79
+ /**
80
+ * @throws {RangeError} When `refreshLeaseMs` or `refreshLockWaitMs` is not
81
+ * a positive number of at most 2^31 − 1 milliseconds.
82
+ */
83
+ constructor(options = {}) {
84
+ this.#leaseMs = timerMs("refreshLeaseMs", options.refreshLeaseMs);
85
+ this.#lockWaitMs = timerMs("refreshLockWaitMs", options.refreshLockWaitMs);
86
+ }
66
87
  /** Stores the session under a fresh random id and returns it as the cookie value. */
67
88
  create(data) {
68
89
  const id = crypto.randomUUID();
@@ -87,24 +108,97 @@ export class MemorySessionStore {
87
108
  return Promise.resolve();
88
109
  }
89
110
  /**
90
- * Runs `refresh` once every earlier call for the same session has settled,
91
- * so every `HonoBff` sharing this store object refreshes a session once.
92
- * Calls for other sessions run alongside.
111
+ * Runs `refresh` once no other call holds the session's lock, so every
112
+ * `HonoBff` sharing this store object refreshes a session once. Calls for
113
+ * other sessions run alongside. `refresh` gets a {@link RefreshLease} that
114
+ * writes and destroys the session only while this call holds the lock.
115
+ *
116
+ * @throws {Error} When the lock stays held past
117
+ * {@linkcode MemorySessionStoreOptions.refreshLockWaitMs}.
93
118
  */
94
119
  async withRefreshLock(cookieValue, refresh) {
95
- const previous = this.#refreshLocks.get(cookieValue) ?? Promise.resolve();
96
- const run = previous.then(refresh);
97
- const released = run.then(() => undefined, () => undefined);
98
- this.#refreshLocks.set(cookieValue, released);
120
+ const lease = await this.#acquire(cookieValue);
121
+ const renewal = Number.isFinite(this.#leaseMs)
122
+ ? setInterval(() => {
123
+ if (this.#holds(cookieValue, lease)) {
124
+ lease.expiresAt = Date.now() + this.#leaseMs;
125
+ }
126
+ else {
127
+ clearInterval(renewal);
128
+ }
129
+ }, this.#leaseMs / 3)
130
+ : undefined;
99
131
  try {
100
- return await run;
132
+ return await refresh(this.#fence(cookieValue, lease));
101
133
  }
102
134
  finally {
103
- if (this.#refreshLocks.get(cookieValue) === released) {
104
- this.#refreshLocks.delete(cookieValue);
135
+ clearInterval(renewal);
136
+ if (this.#leases.get(cookieValue) === lease) {
137
+ this.#leases.delete(cookieValue);
138
+ }
139
+ this.#wakeLockWaiters(cookieValue);
140
+ }
141
+ }
142
+ async #acquire(cookieValue) {
143
+ const deadline = Date.now() + this.#lockWaitMs;
144
+ for (;;) {
145
+ const now = Date.now();
146
+ const held = this.#leases.get(cookieValue);
147
+ if (!held || held.expiresAt <= now) {
148
+ const lease = { expiresAt: now + this.#leaseMs };
149
+ this.#leases.set(cookieValue, lease);
150
+ return lease;
151
+ }
152
+ if (now >= deadline) {
153
+ throw new Error("timed out waiting for the session's refresh lock");
154
+ }
155
+ await this.#lockChange(cookieValue, Math.min(held.expiresAt, deadline) - now);
156
+ }
157
+ }
158
+ #lockChange(cookieValue, timeoutMs) {
159
+ const { promise, resolve } = Promise.withResolvers();
160
+ const waiters = this.#lockWaiters.get(cookieValue) ?? new Set();
161
+ this.#lockWaiters.set(cookieValue, waiters);
162
+ let timer;
163
+ const wake = () => {
164
+ clearTimeout(timer);
165
+ waiters.delete(wake);
166
+ if (waiters.size === 0 &&
167
+ this.#lockWaiters.get(cookieValue) === waiters) {
168
+ this.#lockWaiters.delete(cookieValue);
105
169
  }
170
+ resolve();
171
+ };
172
+ waiters.add(wake);
173
+ if (Number.isFinite(timeoutMs))
174
+ timer = setTimeout(wake, timeoutMs);
175
+ return promise;
176
+ }
177
+ #wakeLockWaiters(cookieValue) {
178
+ for (const wake of [...(this.#lockWaiters.get(cookieValue) ?? [])]) {
179
+ wake();
106
180
  }
107
181
  }
182
+ #holds(cookieValue, lease) {
183
+ return (this.#leases.get(cookieValue) === lease && lease.expiresAt > Date.now());
184
+ }
185
+ #fence(cookieValue, lease) {
186
+ return {
187
+ update: (data) => this.#holds(cookieValue, lease)
188
+ ? this.update(cookieValue, data)
189
+ : Promise.reject(new InvalidGrantError("the session's refresh lock was lost")),
190
+ destroy: (refreshToken) => {
191
+ const session = this.#sessions.get(cookieValue);
192
+ if (!session ||
193
+ session.refreshToken !== refreshToken ||
194
+ !this.#holds(cookieValue, lease)) {
195
+ return Promise.resolve(false);
196
+ }
197
+ this.#sessions.delete(cookieValue);
198
+ return Promise.resolve(true);
199
+ },
200
+ };
201
+ }
108
202
  /**
109
203
  * Destroys the sessions a validated logout_token targets: the specific `sid`
110
204
  * when given, otherwise every session for `sub`. Returns the number
@@ -174,8 +174,12 @@ export interface SessionStoreContractOptions {
174
174
  * requires — test that yourself.
175
175
  *
176
176
  * A store that implements `withRefreshLock` ({@link RefreshLockStore}) also
177
- * runs the lock checks; they are skipped for a store without it. They use one
178
- * store object, so they prove the lock's behavior, not that separate
177
+ * runs the lock checks; they are skipped for a store without it. The lease
178
+ * checks ({@link RefreshLease}) are skipped for a store whose
179
+ * `withRefreshLock` passes no lease. They prove that a lease stops acting once
180
+ * its call releases the lock or another call holds it, not that it stops
181
+ * when the lock expires: expire a lease in a test of your own for that. They
182
+ * use one store object, so they prove the lock's behavior, not that separate
179
183
  * instances share it: point two store objects at one backend in a test of
180
184
  * your own for that.
181
185
  */
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { assert, beforeEach, describe, expect, it } from "vitest";
19
19
  import { delay } from "../../../utils/_delay.js";
20
+ import { InvalidGrantError } from "../../../errors.js";
20
21
  import { sessionStoreMaxAgeMs, supportsRefreshLock, } from "./session-store.js";
21
22
  /**
22
23
  * Seeds a session in `bff.sessionStore` and returns a `Cookie` header
@@ -184,6 +185,16 @@ async function settlesWithin(promise, message) {
184
185
  timer.abort();
185
186
  }
186
187
  }
188
+ function required(lease) {
189
+ assert(lease !== undefined, "withRefreshLock passed a lease to one refresh and none to another");
190
+ return lease;
191
+ }
192
+ async function rejection(promise) {
193
+ const fulfilled = Symbol("fulfilled");
194
+ const outcome = await promise.then(() => fulfilled, (error) => error);
195
+ assert(outcome !== fulfilled, "expected the lease to refuse");
196
+ return outcome;
197
+ }
187
198
  /**
188
199
  * Contract test suite for {@link SessionStore} implementations. Registers
189
200
  * `describe`/`it` blocks from Vitest, so call it at a test
@@ -192,8 +203,12 @@ async function settlesWithin(promise, message) {
192
203
  * requires — test that yourself.
193
204
  *
194
205
  * A store that implements `withRefreshLock` ({@link RefreshLockStore}) also
195
- * runs the lock checks; they are skipped for a store without it. They use one
196
- * store object, so they prove the lock's behavior, not that separate
206
+ * runs the lock checks; they are skipped for a store without it. The lease
207
+ * checks ({@link RefreshLease}) are skipped for a store whose
208
+ * `withRefreshLock` passes no lease. They prove that a lease stops acting once
209
+ * its call releases the lock or another call holds it, not that it stops
210
+ * when the lock expires: expire a lease in a test of your own for that. They
211
+ * use one store object, so they prove the lock's behavior, not that separate
197
212
  * instances share it: point two store objects at one backend in a test of
198
213
  * your own for that.
199
214
  */
@@ -358,6 +373,85 @@ export function runSessionStoreContractTests(options) {
358
373
  expect(await settlesWithin(locking.withRefreshLock(value, () => Promise.resolve("next")), "a refresh that threw kept the lock, so the session can never " +
359
374
  "refresh again")).toBe("next");
360
375
  });
376
+ async function leaseOf(locking, value, skip) {
377
+ const lease = await locking.withRefreshLock(value, (given) => Promise.resolve(given));
378
+ skip(lease === undefined, "withRefreshLock passes the refresh no lease, so a holder that lost " +
379
+ "the lock can still write and destroy the session");
380
+ return required(lease);
381
+ }
382
+ function refreshedSession() {
383
+ return {
384
+ ...sampleSession(),
385
+ tokens: { ...sampleSession().tokens, accessToken: "access-2" },
386
+ refreshToken: "refresh-2",
387
+ };
388
+ }
389
+ it("withRefreshLock passes a lease that writes the session while the lock is held", async ({ skip, }) => {
390
+ const locking = lockStore(skip);
391
+ const value = await locking.create(sampleSession());
392
+ await leaseOf(locking, value, skip);
393
+ const next = await settlesWithin(locking.withRefreshLock(value, (lease) => required(lease).update(refreshedSession())), "a lease's update blocked on the lock its own call holds");
394
+ const fetched = await locking.read(next);
395
+ expect(fetched?.refreshToken).toBe("refresh-2");
396
+ });
397
+ it("withRefreshLock's lease refuses to write or destroy once its call has settled", async ({ skip, }) => {
398
+ const locking = lockStore(skip);
399
+ const value = await locking.create(sampleSession());
400
+ const former = await leaseOf(locking, value, skip);
401
+ const error = await rejection(former.update(refreshedSession()));
402
+ expect(error, "a holder whose lock was released wrote the session, so a holder " +
403
+ "that stalled past its lock would overwrite the next holder's refresh").toBeInstanceOf(InvalidGrantError);
404
+ expect(await former.destroy("refresh-1"), "a holder whose lock was released destroyed the session").toBe(false);
405
+ expect((await locking.read(value))?.refreshToken).toBe("refresh-1");
406
+ });
407
+ it("withRefreshLock's lease cannot act while a later call holds the lock", async ({ skip, }) => {
408
+ const locking = lockStore(skip);
409
+ const value = await locking.create(sampleSession());
410
+ const former = await leaseOf(locking, value, skip);
411
+ const holding = Promise.withResolvers();
412
+ const releaseHolder = Promise.withResolvers();
413
+ const holder = locking.withRefreshLock(value, async (given) => {
414
+ const lease = required(given);
415
+ holding.resolve(lease);
416
+ await releaseHolder.promise;
417
+ return await lease.update(refreshedSession());
418
+ });
419
+ await settlesWithin(holding.promise, "the second call never took the lock");
420
+ const refusedWrite = await rejection(former.update(sampleSession()));
421
+ const refusedDestroy = await former.destroy("refresh-1");
422
+ releaseHolder.resolve();
423
+ await holder;
424
+ expect(refusedWrite).toBeInstanceOf(InvalidGrantError);
425
+ expect(refusedDestroy).toBe(false);
426
+ expect((await locking.read(value))?.refreshToken, "the current holder's write must stand").toBe("refresh-2");
427
+ });
428
+ it("withRefreshLock's lease destroys the session only while it stores the given refresh token", async ({ skip, }) => {
429
+ const locking = lockStore(skip);
430
+ const value = await locking.create(sampleSession());
431
+ await leaseOf(locking, value, skip);
432
+ const results = await locking.withRefreshLock(value, async (given) => {
433
+ const lease = required(given);
434
+ return [
435
+ await lease.destroy("refresh-0"),
436
+ (await locking.read(value)) !== null,
437
+ await lease.destroy("refresh-1"),
438
+ ];
439
+ });
440
+ expect(results, "a destroy for a refresh token the session no longer stores would end " +
441
+ "a session a peer has just refreshed").toStrictEqual([false, true, true]);
442
+ expect(await locking.read(value)).toBe(null);
443
+ });
444
+ it("withRefreshLock's lease never restores a destroyed session", async ({ skip, }) => {
445
+ const locking = lockStore(skip);
446
+ const value = await locking.create(sampleSession());
447
+ await leaseOf(locking, value, skip);
448
+ const error = await locking.withRefreshLock(value, async (lease) => {
449
+ await locking.destroy(value);
450
+ return await rejection(required(lease).update(refreshedSession()));
451
+ });
452
+ expect(error).toBeInstanceOf(InvalidGrantError);
453
+ expect(await locking.read(value)).toBe(null);
454
+ });
361
455
  if (makeBoundedStore) {
362
456
  it("reports the max age it was built with, so a BFF can outlive it", async () => {
363
457
  const bounded = await makeBoundedStore(BOUND_MS);
@@ -129,7 +129,7 @@ export interface LoginOptions extends BaseLoginOptions {
129
129
  * Override the origin (scheme + host) of the authorize endpoint and the
130
130
  * `redirect_uri` for THIS call, keeping their configured paths. Lets one
131
131
  * build-once client serve multiple deployment origins (preview URLs,
132
- * multi-tenant subdomains) — e.g. the Hono BFF passes the trusted request
132
+ * per-application subdomains) — e.g. the Hono BFF passes the trusted request
133
133
  * origin so sign-in works on whichever host the request arrived at. The
134
134
  * callback returns to the same origin, so the matching `exchange` call is
135
135
  * given the same origin.
@@ -139,7 +139,7 @@ export interface LoginOptions extends BaseLoginOptions {
139
139
  * Store the pending authorization request here instead of in the client's
140
140
  * configured `authRequestStorage`, for THIS call. Lets a server bind the
141
141
  * record to the request it belongs to — a per-request cookie store, or a
142
- * per-tenant table — rather than sharing one process-wide store. The
142
+ * per-application table — rather than sharing one process-wide store. The
143
143
  * matching `exchangeAuthorizationCode` call MUST be given a storage that
144
144
  * can read the record back.
145
145
  */
@@ -44,7 +44,7 @@ export interface DiscoveryCacheEntry {
44
44
  * collapse concurrent misses into one `load` call — a get/set pair cannot.
45
45
  *
46
46
  * Metadata is issuer-scoped and public: it carries no client id, secret, or
47
- * tenant data, so one cache may serve many tenants pointed at the same issuer.
47
+ * user data, so one cache may serve many clients pointed at the same issuer.
48
48
  * Do share a cache across clients that reach an issuer the same way; do not
49
49
  * share one between a stubbed transport and live traffic.
50
50
  *
@@ -90,8 +90,8 @@ export interface MemoryDiscoveryCacheOptions {
90
90
  /**
91
91
  * Maximum number of issuers held. Defaults to
92
92
  * {@link DEFAULT_DISCOVERY_CACHE_MAX_ENTRIES}. Expired entries are dropped
93
- * first, then the least recently used, so a multi-tenant server with an
94
- * unbounded issuer list cannot grow without limit.
93
+ * first, then the least recently used, so a server that reaches an
94
+ * unbounded list of issuers cannot grow without limit.
95
95
  */
96
96
  maxEntries?: number;
97
97
  }
@@ -118,8 +118,8 @@ export interface VerifyCaptchaOptions {
118
118
  * What to do when the provider throws (an outage). `true` (the default)
119
119
  * fails open — the request passes with `degraded: true` — so a provider
120
120
  * outage falls back to your rate-limit/lockout floor instead of blocking
121
- * every user. `false` fails closed for a tenant that would rather refuse than
122
- * degrade.
121
+ * every user. `false` fails closed for a deployment that would rather refuse
122
+ * than degrade.
123
123
  */
124
124
  failOpen?: boolean;
125
125
  }
@@ -47,7 +47,7 @@ export interface RequireConditions {
47
47
  scope?: string | string[];
48
48
  /** Permission(s) the subject must hold ({@link Authorization.can}). */
49
49
  permission?: string | string[];
50
- /** Tenant-wide role(s) the subject must hold ({@link Authorization.hasRole}). */
50
+ /** Issuer-wide role(s) the subject must hold ({@link Authorization.hasRole}). */
51
51
  role?: string | string[];
52
52
  /**
53
53
  * Role(s) the subject must hold in the active organization
@@ -78,9 +78,9 @@ export interface AuthorizationFailure {
78
78
  export interface AuthorizationInit {
79
79
  /** The scope granted to the client, when known. */
80
80
  scope?: AbstractScope | null;
81
- /** Tenant-wide role slugs the subject holds. */
81
+ /** Issuer-wide role slugs the subject holds, outside any organization. */
82
82
  roles?: Iterable<string>;
83
- /** Permissions the subject holds (tenant-level plus the active organization's). */
83
+ /** Permissions the subject holds (issuer-wide plus the active organization's). */
84
84
  permissions?: Iterable<string>;
85
85
  /** The active organization context, when the credential carries one. */
86
86
  organization?: OrganizationContext | null;
@@ -111,7 +111,7 @@ export interface AuthorizationInit {
111
111
  export declare class Authorization {
112
112
  /** The scope granted to the client, or `null` when unknown. */
113
113
  readonly scope: AbstractScope | null;
114
- /** Tenant-wide role slugs the subject holds. */
114
+ /** Issuer-wide role slugs the subject holds, outside any organization. */
115
115
  readonly roles: ReadonlySet<string>;
116
116
  /** Permissions the subject holds in the credential's scope of issue. */
117
117
  readonly permissions: ReadonlySet<string>;
@@ -130,7 +130,7 @@ export declare class Authorization {
130
130
  * permission is the contract, role names are presentation.
131
131
  */
132
132
  can(permission: string): boolean;
133
- /** Whether the subject holds the tenant-wide role `role`. */
133
+ /** Whether the subject holds the issuer-wide role `role`. */
134
134
  hasRole(role: string): boolean;
135
135
  /** Whether the subject holds `role` in the active organization. */
136
136
  hasOrgRole(role: string): boolean;
@@ -42,7 +42,7 @@
42
42
  export class Authorization {
43
43
  /** The scope granted to the client, or `null` when unknown. */
44
44
  scope;
45
- /** Tenant-wide role slugs the subject holds. */
45
+ /** Issuer-wide role slugs the subject holds, outside any organization. */
46
46
  roles;
47
47
  /** Permissions the subject holds in the credential's scope of issue. */
48
48
  permissions;
@@ -70,7 +70,7 @@ export class Authorization {
70
70
  can(permission) {
71
71
  return this.permissions.has(permission);
72
72
  }
73
- /** Whether the subject holds the tenant-wide role `role`. */
73
+ /** Whether the subject holds the issuer-wide role `role`. */
74
74
  hasRole(role) {
75
75
  return this.roles.has(role);
76
76
  }
@@ -11,7 +11,7 @@
11
11
  * interface MyClient extends ClientInterface {
12
12
  * name: string;
13
13
  * confidential: boolean;
14
- * tenantId: string;
14
+ * logoUri: string;
15
15
  * }
16
16
  * ```
17
17
  */
@@ -32,12 +32,12 @@ import type { AuthFormClassNames } from "./types.ts";
32
32
  *
33
33
  * @example Theme a custom field to match the form around it
34
34
  * ```tsx
35
- * function TenantField(props: { fieldProps: AuthFieldProps }) {
35
+ * function InviteCodeField(props: { fieldProps: AuthFieldProps }) {
36
36
  * const classNames = useAuthFormClassNames();
37
37
  * return (
38
38
  * <div className={classNames.field}>
39
39
  * <label className={classNames.label} htmlFor={props.fieldProps.id}>
40
- * Tenant
40
+ * Invite code
41
41
  * </label>
42
42
  * <input {...props.fieldProps} className={classNames.input} />
43
43
  * </div>
@@ -49,12 +49,12 @@ function slotKey(classNames) {
49
49
  *
50
50
  * @example Theme a custom field to match the form around it
51
51
  * ```tsx
52
- * function TenantField(props: { fieldProps: AuthFieldProps }) {
52
+ * function InviteCodeField(props: { fieldProps: AuthFieldProps }) {
53
53
  * const classNames = useAuthFormClassNames();
54
54
  * return (
55
55
  * <div className={classNames.field}>
56
56
  * <label className={classNames.label} htmlFor={props.fieldProps.id}>
57
- * Tenant
57
+ * Invite code
58
58
  * </label>
59
59
  * <input {...props.fieldProps} className={classNames.input} />
60
60
  * </div>
@@ -9,12 +9,13 @@
9
9
  * - Server metadata (RFC 8414)
10
10
  * - Device authorization (RFC 8628)
11
11
  *
12
- * **Configuration.** A single instance serves every tenant/origin. The only
13
- * configuration seam is `resolve(request)`, which returns the services the
14
- * server's own endpoints need (`{ clientService, tokenService }`) plus the
15
- * issuer/endpoints. Each grant resolves its own services independently. For a
16
- * single-tenant server, `resolve` is a constant function (create the services
17
- * once, outside it, and return them every time).
12
+ * **Configuration.** The only configuration seam is `resolve(request)`, which
13
+ * returns the per-request configuration: the services the server's own
14
+ * endpoints need (`{ clientService, tokenService }`) plus the issuer and
15
+ * endpoint URLs. Because it runs per request, one server can serve the origins
16
+ * or applications a business operates. Each grant resolves its own services
17
+ * independently. For a single deployment, `resolve` is a constant function
18
+ * (create the services once, outside it, and return them every time).
18
19
  *
19
20
  * @see https://datatracker.ietf.org/doc/html/rfc6749
20
21
  * @module
@@ -161,9 +162,11 @@ export interface AuthorizationServerContext<Client extends ClientInterface, User
161
162
  */
162
163
  export interface AuthorizationServerOptions<Client extends ClientInterface, User, S extends AbstractScope> extends Omit<ResourceServerOptions<Client, User, S>, "resolve"> {
163
164
  /**
164
- * Resolves the per-request context — the server's own services plus the
165
- * issuer/endpoints. This is the only services seam: for a single-tenant
166
- * server it is a constant function returning the same context every time.
165
+ * Resolves the per-request configuration — the server's own services plus
166
+ * the issuer and endpoint URLs — so one server can serve the origins or
167
+ * applications a business operates. This is the only services seam: for a
168
+ * single deployment it is a constant function returning the same context
169
+ * every time.
167
170
  */
168
171
  resolve: (request: Request) => AuthorizationServerContext<Client, User, S> | Promise<AuthorizationServerContext<Client, User, S>>;
169
172
  /** Grants the server supports, keyed by `grant_type` (each key must equal `grant.grantType`). */
@@ -406,8 +409,8 @@ export declare const ENDPOINT_METHODS: Readonly<Record<EndpointKey, readonly str
406
409
  * Framework-agnostic: each `handle*Request` method takes a web `Request` and
407
410
  * returns a `Response`, so an adapter routes its endpoints to them. Extends
408
411
  * {@linkcode ResourceServer}, so the same instance also validates bearer
409
- * tokens. A single instance serves every tenant/origin via the `resolve`
410
- * callback in {@linkcode AuthorizationServerOptions}.
412
+ * tokens. Its issuer, endpoint URLs and services come per request from the
413
+ * `resolve` callback in {@linkcode AuthorizationServerOptions}.
411
414
  *
412
415
  * The handlers throw {@linkcode HttpError} subclasses on failure (e.g.
413
416
  * {@linkcode InvalidRequestError}, {@linkcode InvalidClientError},
@@ -9,12 +9,13 @@
9
9
  * - Server metadata (RFC 8414)
10
10
  * - Device authorization (RFC 8628)
11
11
  *
12
- * **Configuration.** A single instance serves every tenant/origin. The only
13
- * configuration seam is `resolve(request)`, which returns the services the
14
- * server's own endpoints need (`{ clientService, tokenService }`) plus the
15
- * issuer/endpoints. Each grant resolves its own services independently. For a
16
- * single-tenant server, `resolve` is a constant function (create the services
17
- * once, outside it, and return them every time).
12
+ * **Configuration.** The only configuration seam is `resolve(request)`, which
13
+ * returns the per-request configuration: the services the server's own
14
+ * endpoints need (`{ clientService, tokenService }`) plus the issuer and
15
+ * endpoint URLs. Because it runs per request, one server can serve the origins
16
+ * or applications a business operates. Each grant resolves its own services
17
+ * independently. For a single deployment, `resolve` is a constant function
18
+ * (create the services once, outside it, and return them every time).
18
19
  *
19
20
  * @see https://datatracker.ietf.org/doc/html/rfc6749
20
21
  * @module
@@ -154,8 +155,8 @@ function isDeviceAuthorizationGrant(grant) {
154
155
  * Framework-agnostic: each `handle*Request` method takes a web `Request` and
155
156
  * returns a `Response`, so an adapter routes its endpoints to them. Extends
156
157
  * {@linkcode ResourceServer}, so the same instance also validates bearer
157
- * tokens. A single instance serves every tenant/origin via the `resolve`
158
- * callback in {@linkcode AuthorizationServerOptions}.
158
+ * tokens. Its issuer, endpoint URLs and services come per request from the
159
+ * `resolve` callback in {@linkcode AuthorizationServerOptions}.
159
160
  *
160
161
  * The handlers throw {@linkcode HttpError} subclasses on failure (e.g.
161
162
  * {@linkcode InvalidRequestError}, {@linkcode InvalidClientError},
@@ -22,17 +22,18 @@ export interface ResourceServerServices<Client extends ClientInterface, User, S
22
22
  }
23
23
  /** Per-request context resolved by a {@link ResourceServer}. */
24
24
  export interface ResourceServerContext<Client extends ClientInterface, User, S extends AbstractScope> {
25
- /** Tenant-scoped services to use for this request. */
25
+ /** The services to use for this request. */
26
26
  services: ResourceServerServices<Client, User, S>;
27
27
  }
28
28
  /** Options for configuring the resource server. */
29
29
  export interface ResourceServerOptions<Client extends ClientInterface, User, S extends AbstractScope> {
30
30
  /**
31
- * Resolves the per-request context — the (e.g. tenant-scoped) {@link
32
- * ResourceServerServices} for THIS request — so a single instance serves
33
- * every tenant/origin. This is the only services seam: for a single-tenant
34
- * server it is a constant function returning the same services every time
35
- * (create the services once, outside `resolve`, and return them here).
31
+ * Resolves the per-request configuration — the {@link
32
+ * ResourceServerServices} for THIS request — so one server can serve the
33
+ * origins or applications a business operates. This is the only services
34
+ * seam: for a single deployment it is a constant function returning the
35
+ * same services every time (create the services once, outside `resolve`,
36
+ * and return them here).
36
37
  */
37
38
  resolve: (request: Request) => ResourceServerContext<Client, User, S> | Promise<ResourceServerContext<Client, User, S>>;
38
39
  /** Scope constructor used to parse and compare scopes. Defaults to {@linkcode BasicScope}. */
@@ -133,10 +134,9 @@ export interface AuthenticatedContext<Client extends ClientInterface, User, S ex
133
134
  * authentication. Framework-agnostic: it works on web `Request`/`Response`, so
134
135
  * an adapter (Hono, etc.) wires {@linkcode authenticate} into its middleware.
135
136
  *
136
- * A single instance serves every tenant/origin; per-request services come from
137
- * the `resolve` callback in {@linkcode ResourceServerOptions}. For a
138
- * single-tenant server, `resolve` is a constant function returning the same
139
- * services every time.
137
+ * Per-request services come from the `resolve` callback in
138
+ * {@linkcode ResourceServerOptions}. For a single deployment, `resolve` is a
139
+ * constant function returning the same services every time.
140
140
  *
141
141
  * {@linkcode authenticate} throws {@linkcode HttpError} subclasses on failure
142
142
  * ({@linkcode AccessDeniedError} when no token is sent,
@@ -31,10 +31,9 @@ const CHALLENGE_ERROR_CODES = new Set([
31
31
  * authentication. Framework-agnostic: it works on web `Request`/`Response`, so
32
32
  * an adapter (Hono, etc.) wires {@linkcode authenticate} into its middleware.
33
33
  *
34
- * A single instance serves every tenant/origin; per-request services come from
35
- * the `resolve` callback in {@linkcode ResourceServerOptions}. For a
36
- * single-tenant server, `resolve` is a constant function returning the same
37
- * services every time.
34
+ * Per-request services come from the `resolve` callback in
35
+ * {@linkcode ResourceServerOptions}. For a single deployment, `resolve` is a
36
+ * constant function returning the same services every time.
38
37
  *
39
38
  * {@linkcode authenticate} throws {@linkcode HttpError} subclasses on failure
40
39
  * ({@linkcode AccessDeniedError} when no token is sent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.23.0",
3
+ "version": "0.24.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",