@udibo/oauth2 0.21.0 → 0.21.2

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.
@@ -319,10 +319,14 @@ export interface HonoBffOptions {
319
319
  * server-side store is needed. It cannot list sessions, so it does not
320
320
  * support {@link HonoBffOptions.backchannelLogout}, and sign-out cannot
321
321
  * invalidate a copied cookie: it stays valid until its `maxAgeMs`. Use a
322
- * shared {@link SessionStore} when you need revocation.
322
+ * shared {@link SessionStore} when you need revocation. Against an
323
+ * authorization server that rotates refresh tokens, a request still
324
+ * carrying the cookie from before a refresh replays the old token and
325
+ * trips reuse detection.
323
326
  * - 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`.
327
+ * for server-side revocation and back-channel logout. Implement
328
+ * `RefreshLockStore` (`withRefreshLock`) on it so instances refresh a
329
+ * session one at a time, and check it with `runSessionStoreContractTests`.
326
330
  * - {@link MemorySessionStore} for a single process, tests and local
327
331
  * development. A session it holds is unknown to every other instance, so
328
332
  * behind a load balancer the user appears signed out.
@@ -58,8 +58,12 @@ 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, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.js";
61
+ import { DEFAULT_SESSION_MAX_AGE_MS, sessionStoreMaxAgeMs, supportsBackchannelLogout, supportsRefreshLock, } from "./session-store.js";
62
62
  import { buildDownstreamHeaders, buildUpstreamHeaders, isInvalidTokenChallenge, resolveForwardHeaders, resolveProxyUrl, } from "./proxy.js";
63
+ function refreshedSince(seen, current) {
64
+ return (current.refreshToken !== seen.refreshToken ||
65
+ current.tokens.accessToken !== seen.tokens.accessToken);
66
+ }
63
67
  function isRevokedGrant(error) {
64
68
  if (!isOAuth2Error(error))
65
69
  return false;
@@ -1005,12 +1009,35 @@ export class HonoBff {
1005
1009
  const inFlight = this.#refreshes.get(entry.cookieValue);
1006
1010
  if (inFlight)
1007
1011
  return inFlight;
1008
- const pending = this.#exchangeRefresh(entry).finally(() => {
1012
+ const pending = this.#lockedRefresh(entry).finally(() => {
1009
1013
  this.#refreshes.delete(entry.cookieValue);
1010
1014
  });
1011
1015
  this.#refreshes.set(entry.cookieValue, pending);
1012
1016
  return pending;
1013
1017
  }
1018
+ async #lockedRefresh(entry) {
1019
+ const store = this.#store;
1020
+ if (!supportsRefreshLock(store))
1021
+ return await this.#exchangeRefresh(entry);
1022
+ const { cookieValue } = entry;
1023
+ try {
1024
+ return await store.withRefreshLock(cookieValue, async () => {
1025
+ const current = await store.read(cookieValue);
1026
+ if (!current)
1027
+ return { status: "revoked" };
1028
+ if (refreshedSince(entry.data, current)) {
1029
+ return {
1030
+ status: "refreshed",
1031
+ entry: { cookieValue, data: current },
1032
+ };
1033
+ }
1034
+ return await this.#exchangeRefresh({ cookieValue, data: current });
1035
+ });
1036
+ }
1037
+ catch {
1038
+ return { status: "unavailable" };
1039
+ }
1040
+ }
1014
1041
  async #exchangeRefresh(entry) {
1015
1042
  const { cookieValue, data } = entry;
1016
1043
  if (!data.refreshToken)
@@ -12,12 +12,13 @@
12
12
  * the memory pair serves a single process, tests and local development.
13
13
  *
14
14
  * Need a server-side store (Postgres, Redis, …)? Implement the
15
- * {@link SessionStore} interface and verify it with
16
- * `runSessionStoreContractTests` from `@udibo/oauth2/hono/bff/testing`.
15
+ * {@link SessionStore} interface, plus {@link RefreshLockStore} when several
16
+ * instances share it, and verify it with `runSessionStoreContractTests` from
17
+ * `@udibo/oauth2/hono/bff/testing`.
17
18
  *
18
19
  * @module
19
20
  */
20
21
  export { type BackchannelLogoutSubject, type BffCallbackError, HonoBff, type HonoBffBackchannelOptions, type HonoBffCookieOptions, type HonoBffCsrfOptions, type HonoBffOptions, type HonoBffPaths, type HonoBffResourceServer, } from "./bff.ts";
21
22
  export { type AuthRequestCookieOptions, type AuthRequestStorageFactory, EncryptedCookieAuthRequestStorage, encryptedCookieAuthRequestStorage, type EncryptedCookieAuthRequestStorageOptions, MemoryAuthRequestStorageFactory, } from "./auth-request-store.ts";
22
23
  export { DEFAULT_PROXY_FORWARD_HEADERS, type HonoBffProxyOptions, } from "./proxy.ts";
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";
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";
@@ -12,8 +12,9 @@
12
12
  * the memory pair serves a single process, tests and local development.
13
13
  *
14
14
  * Need a server-side store (Postgres, Redis, …)? Implement the
15
- * {@link SessionStore} interface and verify it with
16
- * `runSessionStoreContractTests` from `@udibo/oauth2/hono/bff/testing`.
15
+ * {@link SessionStore} interface, plus {@link RefreshLockStore} when several
16
+ * instances share it, and verify it with `runSessionStoreContractTests` from
17
+ * `@udibo/oauth2/hono/bff/testing`.
17
18
  *
18
19
  * @module
19
20
  */
@@ -69,6 +69,54 @@ export interface BackchannelLogoutStore {
69
69
  }
70
70
  /** Whether a store implements the {@link BackchannelLogoutStore} capability. */
71
71
  export declare function supportsBackchannelLogout(store: SessionStore): store is SessionStore & BackchannelLogoutStore;
72
+ /**
73
+ * Optional {@link SessionStore} capability: one token refresh at a time per
74
+ * session, across every instance that shares the store. A store opts in by
75
+ * implementing this method (it is duck-typed at runtime).
76
+ *
77
+ * Without it, `HonoBff` shares a refresh only between requests on the same
78
+ * instance. Two instances that refresh one session at once both present its
79
+ * refresh token: against an authorization server that rotates refresh tokens,
80
+ * the loser's exchange fails, or trips reuse detection and revokes the whole
81
+ * rotation family, and the user is signed out. With it, the BFF re-reads the
82
+ * session while it holds the lock and uses a peer's fresh tokens instead of
83
+ * spending the refresh token again. Implement it on any store more than one
84
+ * instance shares. {@link EncryptedCookieSessionStore} cannot: its sessions
85
+ * live in each browser's cookie, with nothing on the server to lock.
86
+ */
87
+ export interface RefreshLockStore {
88
+ /**
89
+ * Runs `refresh` while no other `withRefreshLock` call for the same
90
+ * `cookieValue`, on any instance, is running, and settles with its result.
91
+ * Calls for other sessions must not wait on each other.
92
+ *
93
+ * - The lock is keyed by the cookie value, so it holds only for a store
94
+ * whose `update` returns the cookie value it was given. A store that
95
+ * issues a new value on update cannot implement it.
96
+ * - The lock must not block this store's own `read`, `update` or `destroy`:
97
+ * `refresh` reads, writes and can destroy the session it locks.
98
+ * - Release it when `refresh` settles, including when it throws.
99
+ * - The lock covers the whole refresh: the token request and the store's
100
+ * own reads and writes. With `DirectClient` a refresh can take about 30
101
+ * seconds when its discovery cache is cold, since discovery tries two
102
+ * well-known paths and every request has a 10-second deadline. If the
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.
106
+ * - Reject when the lock cannot be acquired within a bound you choose.
107
+ * `HonoBff` then answers `502` `temporarily_unavailable` and keeps the
108
+ * session.
109
+ *
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.
115
+ */
116
+ withRefreshLock<T>(cookieValue: string, refresh: () => Promise<T>): Promise<T>;
117
+ }
118
+ /** Whether a store implements the {@link RefreshLockStore} capability. */
119
+ export declare function supportsRefreshLock(store: SessionStore): store is SessionStore & RefreshLockStore;
72
120
  /**
73
121
  * Default session lifetime — 14 days — shared by
74
122
  * {@link EncryptedCookieSessionStore} and `HonoBff`'s
@@ -129,7 +177,7 @@ export interface SessionStore {
129
177
  * restarts. Suitable for single-process development or tests; use a
130
178
  * persistent store in production.
131
179
  */
132
- export declare class MemorySessionStore implements SessionStore, BackchannelLogoutStore {
180
+ export declare class MemorySessionStore implements SessionStore, BackchannelLogoutStore, RefreshLockStore {
133
181
  #private;
134
182
  /** Stores the session under a fresh random id and returns it as the cookie value. */
135
183
  create(data: SessionData): Promise<string>;
@@ -139,6 +187,12 @@ export declare class MemorySessionStore implements SessionStore, BackchannelLogo
139
187
  update(cookieValue: string, data: SessionData): Promise<string>;
140
188
  /** Removes the session record for the given id. */
141
189
  destroy(cookieValue: string): Promise<void>;
190
+ /**
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.
194
+ */
195
+ withRefreshLock<T>(cookieValue: string, refresh: () => Promise<T>): Promise<T>;
142
196
  /**
143
197
  * Destroys the sessions a validated logout_token targets: the specific `sid`
144
198
  * when given, otherwise every session for `sub`. Returns the number
@@ -30,6 +30,10 @@ export function supportsBackchannelLogout(store) {
30
30
  return (typeof store.destroyByLogout ===
31
31
  "function");
32
32
  }
33
+ /** Whether a store implements the {@link RefreshLockStore} capability. */
34
+ export function supportsRefreshLock(store) {
35
+ return (typeof store.withRefreshLock === "function");
36
+ }
33
37
  /**
34
38
  * Default session lifetime — 14 days — shared by
35
39
  * {@link EncryptedCookieSessionStore} and `HonoBff`'s
@@ -58,6 +62,7 @@ export function sessionStoreMaxAgeMs(store) {
58
62
  */
59
63
  export class MemorySessionStore {
60
64
  #sessions = new Map();
65
+ #refreshLocks = new Map();
61
66
  /** Stores the session under a fresh random id and returns it as the cookie value. */
62
67
  create(data) {
63
68
  const id = crypto.randomUUID();
@@ -81,6 +86,25 @@ export class MemorySessionStore {
81
86
  this.#sessions.delete(cookieValue);
82
87
  return Promise.resolve();
83
88
  }
89
+ /**
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.
93
+ */
94
+ 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);
99
+ try {
100
+ return await run;
101
+ }
102
+ finally {
103
+ if (this.#refreshLocks.get(cookieValue) === released) {
104
+ this.#refreshLocks.delete(cookieValue);
105
+ }
106
+ }
107
+ }
84
108
  /**
85
109
  * Destroys the sessions a validated logout_token targets: the specific `sid`
86
110
  * when given, otherwise every session for `sub`. Returns the number
@@ -10,7 +10,8 @@
10
10
  * The runner-style helper {@link runSessionStoreContractTests} is
11
11
  * available for verifying custom {@link SessionStore} implementations
12
12
  * (Postgres, Redis, DynamoDB, …) satisfy the create / read / update /
13
- * destroy contract.
13
+ * destroy contract, and the refresh lock when the store implements
14
+ * `RefreshLockStore`.
14
15
  *
15
16
  * @module
16
17
  */
@@ -171,5 +172,11 @@ export interface SessionStoreContractOptions {
171
172
  * module's top level. It does not check that a stateful store's `update`
172
173
  * rejects a missing or destroyed session, which {@link SessionStore.update}
173
174
  * requires — test that yourself.
175
+ *
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
179
+ * instances share it: point two store objects at one backend in a test of
180
+ * your own for that.
174
181
  */
175
182
  export declare function runSessionStoreContractTests(options: SessionStoreContractOptions): void;
@@ -10,13 +10,14 @@
10
10
  * The runner-style helper {@link runSessionStoreContractTests} is
11
11
  * available for verifying custom {@link SessionStore} implementations
12
12
  * (Postgres, Redis, DynamoDB, …) satisfy the create / read / update /
13
- * destroy contract.
13
+ * destroy contract, and the refresh lock when the store implements
14
+ * `RefreshLockStore`.
14
15
  *
15
16
  * @module
16
17
  */
17
18
  import { assert, beforeEach, describe, expect, it } from "vitest";
18
19
  import { delay } from "../../../utils/_delay.js";
19
- import { sessionStoreMaxAgeMs, } from "./session-store.js";
20
+ import { sessionStoreMaxAgeMs, supportsRefreshLock, } from "./session-store.js";
20
21
  /**
21
22
  * Seeds a session in `bff.sessionStore` and returns a `Cookie` header
22
23
  * value (`<name>=<value>`) ready to attach to subsequent
@@ -165,12 +166,36 @@ function parseCookieHeader(header, name) {
165
166
  throw new Error(`parseCookieHeader: cookie "${name}" not found in header "${header}"`);
166
167
  }
167
168
  const BOUND_MS = 1000;
169
+ const LOCK_WAIT_MS = 2000;
170
+ const LOCK_CONTENTION_MS = 100;
171
+ async function settlesWithin(promise, message) {
172
+ const timer = new AbortController();
173
+ const { signal } = timer;
174
+ const timedOut = Symbol("timed out");
175
+ try {
176
+ const result = await Promise.race([
177
+ promise,
178
+ delay(LOCK_WAIT_MS, { signal }).then(() => timedOut),
179
+ ]);
180
+ assert(result !== timedOut, message);
181
+ return result;
182
+ }
183
+ finally {
184
+ timer.abort();
185
+ }
186
+ }
168
187
  /**
169
188
  * Contract test suite for {@link SessionStore} implementations. Registers
170
189
  * `describe`/`it` blocks from Vitest, so call it at a test
171
190
  * module's top level. It does not check that a stateful store's `update`
172
191
  * rejects a missing or destroyed session, which {@link SessionStore.update}
173
192
  * requires — test that yourself.
193
+ *
194
+ * 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
197
+ * instances share it: point two store objects at one backend in a test of
198
+ * your own for that.
174
199
  */
175
200
  export function runSessionStoreContractTests(options) {
176
201
  const destroyClearsRead = options.destroyClearsRead ?? true;
@@ -244,6 +269,95 @@ export function runSessionStoreContractTests(options) {
244
269
  "session does. Supply makeBoundedStore so the contract can prove " +
245
270
  "the bound is real.").toStrictEqual(false);
246
271
  });
272
+ function lockStore(skip) {
273
+ skip(!supportsRefreshLock(store), "the store does not implement withRefreshLock");
274
+ return store;
275
+ }
276
+ it("withRefreshLock runs one refresh of a session at a time", async ({ skip, }) => {
277
+ const locking = lockStore(skip);
278
+ const value = await locking.create(sampleSession());
279
+ const firstStarted = Promise.withResolvers();
280
+ const releaseFirst = Promise.withResolvers();
281
+ const order = [];
282
+ const first = locking.withRefreshLock(value, async () => {
283
+ order.push("first started");
284
+ firstStarted.resolve();
285
+ await releaseFirst.promise;
286
+ order.push("first settled");
287
+ return "first";
288
+ });
289
+ await settlesWithin(firstStarted.promise, "withRefreshLock never ran a refresh on a session nobody else holds");
290
+ const second = locking.withRefreshLock(value, () => {
291
+ order.push("second started");
292
+ return Promise.resolve("second");
293
+ });
294
+ await delay(LOCK_CONTENTION_MS);
295
+ expect(order, "a second refresh of one session started while the first still held " +
296
+ "the lock, so both would spend the same refresh token").toStrictEqual(["first started"]);
297
+ releaseFirst.resolve();
298
+ expect(await settlesWithin(Promise.all([first, second]), "the second refresh never ran after the first released the lock")).toStrictEqual(["first", "second"]);
299
+ expect(order).toStrictEqual([
300
+ "first started",
301
+ "first settled",
302
+ "second started",
303
+ ]);
304
+ });
305
+ it("withRefreshLock does not hold up another session's refresh", async ({ skip, }) => {
306
+ const locking = lockStore(skip);
307
+ const held = await locking.create(sampleSession());
308
+ const other = await locking.create(sampleSession());
309
+ const releaseHeld = Promise.withResolvers();
310
+ const holding = locking.withRefreshLock(held, () => releaseHeld.promise);
311
+ expect(await settlesWithin(locking.withRefreshLock(other, () => Promise.resolve("other")), "a refresh of one session waited on another session's lock")).toBe("other");
312
+ releaseHeld.resolve();
313
+ await holding;
314
+ });
315
+ it("withRefreshLock lets the refresh read and update the session it locks", async ({ skip, }) => {
316
+ const locking = lockStore(skip);
317
+ const value = await locking.create(sampleSession());
318
+ const updated = {
319
+ ...sampleSession(),
320
+ tokens: { ...sampleSession().tokens, accessToken: "access-2" },
321
+ refreshToken: "refresh-2",
322
+ };
323
+ const fetched = await settlesWithin(locking.withRefreshLock(value, async () => {
324
+ const next = await locking.update(value, updated);
325
+ return await locking.read(next);
326
+ }), "read or update blocked inside withRefreshLock: HonoBff reads and " +
327
+ "writes the session while it holds the lock, so it would deadlock");
328
+ expect(fetched?.tokens.accessToken).toBe("access-2");
329
+ expect(fetched?.refreshToken).toBe("refresh-2");
330
+ });
331
+ it("withRefreshLock lets the refresh destroy the session it locks", async ({ skip, }) => {
332
+ const locking = lockStore(skip);
333
+ const value = await locking.create(sampleSession());
334
+ await settlesWithin(locking.withRefreshLock(value, () => locking.destroy(value)), "destroy blocked inside withRefreshLock: HonoBff destroys the " +
335
+ "session it locks when the authorization server refuses the " +
336
+ "refresh, so it would deadlock");
337
+ });
338
+ it("withRefreshLock runs a refresh that was waiting when the holder threw", async ({ skip, }) => {
339
+ const locking = lockStore(skip);
340
+ const value = await locking.create(sampleSession());
341
+ const failure = new Error("token endpoint unreachable");
342
+ const releaseFirst = Promise.withResolvers();
343
+ const first = locking.withRefreshLock(value, async () => {
344
+ await releaseFirst.promise;
345
+ throw failure;
346
+ });
347
+ const waiting = locking.withRefreshLock(value, () => Promise.resolve("waiting"));
348
+ releaseFirst.resolve();
349
+ await expect(first).rejects.toBe(failure);
350
+ expect(await settlesWithin(waiting.catch((error) => error), "a refresh queued behind one that threw never ran, so every " +
351
+ "request waiting on that session hangs"), "a refresh queued behind one that threw must run, not inherit its error").toBe("waiting");
352
+ });
353
+ it("withRefreshLock releases the lock when the refresh throws", async ({ skip, }) => {
354
+ const locking = lockStore(skip);
355
+ const value = await locking.create(sampleSession());
356
+ const failure = new Error("token endpoint unreachable");
357
+ await expect(locking.withRefreshLock(value, () => Promise.reject(failure))).rejects.toBe(failure);
358
+ expect(await settlesWithin(locking.withRefreshLock(value, () => Promise.resolve("next")), "a refresh that threw kept the lock, so the session can never " +
359
+ "refresh again")).toBe("next");
360
+ });
247
361
  if (makeBoundedStore) {
248
362
  it("reports the max age it was built with, so a BFF can outlive it", async () => {
249
363
  const bounded = await makeBoundedStore(BOUND_MS);
@@ -24,6 +24,14 @@ export declare const ID_TOKEN_EXPIRY_LEEWAY_SECONDS = 120;
24
24
  * OAuth2 error carries a description the provider wrote.
25
25
  */
26
26
  export declare function describeError(error: unknown): string;
27
+ /**
28
+ * Deadline, in milliseconds, on each request a connector sends to a provider —
29
+ * the token exchange and the profile, UserInfo and key-set reads. Send it as
30
+ * `signal: packageDeadlineSignal(AbortSignal.timeout(...))` on the `fetch`
31
+ * init: that one signal covers the connect, the headers and the whole body, so
32
+ * the body read after it needs no deadline of its own.
33
+ */
34
+ export declare const PROVIDER_REQUEST_TIMEOUT_MS = 10000;
27
35
  /**
28
36
  * Response bytes a connector reads from a provider endpoint — token, profile,
29
37
  * UserInfo, or key set — before refusing the response.
@@ -37,6 +37,14 @@ export function describeError(error) {
37
37
  }
38
38
  return sanitizeProviderText(error instanceof Error ? error.message : String(error));
39
39
  }
40
+ /**
41
+ * Deadline, in milliseconds, on each request a connector sends to a provider —
42
+ * the token exchange and the profile, UserInfo and key-set reads. Send it as
43
+ * `signal: packageDeadlineSignal(AbortSignal.timeout(...))` on the `fetch`
44
+ * init: that one signal covers the connect, the headers and the whole body, so
45
+ * the body read after it needs no deadline of its own.
46
+ */
47
+ export const PROVIDER_REQUEST_TIMEOUT_MS = 10_000;
40
48
  /**
41
49
  * Response bytes a connector reads from a provider endpoint — token, profile,
42
50
  * UserInfo, or key set — before refusing the response.
@@ -10,8 +10,6 @@
10
10
  */
11
11
  /** Response bytes accepted from a token endpoint before the exchange is refused. */
12
12
  export declare const MAX_TOKEN_RESPONSE_BYTES: number;
13
- /** Wall-clock budget for the exchange, covering the connect and the body read. */
14
- export declare const TOKEN_EXCHANGE_TIMEOUT_MS = 10000;
15
13
  /** Input for {@link runTokenExchange}. */
16
14
  export interface TokenExchangeInput {
17
15
  /** Provider id carried on every thrown {@link ExternalAuthError}. */
@@ -39,7 +37,7 @@ export interface TokenExchangeInput {
39
37
  httpErrorHint: string;
40
38
  /** Guidance appended for the OAuth2 error code the provider reported. */
41
39
  tokenErrorHint: (code: string | undefined) => string;
42
- /** Exchange deadline. Defaults to {@link TOKEN_EXCHANGE_TIMEOUT_MS}. */
40
+ /** Exchange deadline. Defaults to {@link PROVIDER_REQUEST_TIMEOUT_MS}. */
43
41
  timeoutMs?: number;
44
42
  }
45
43
  /** A token response that cleared every step of {@link runTokenExchange}. */
@@ -9,12 +9,10 @@
9
9
  * @module
10
10
  */
11
11
  import { packageDeadlineSignal } from "../../utils/_default-fetch.js";
12
- import { describeError, MAX_PROVIDER_RESPONSE_BYTES, sanitizeProviderText, } from "./_shared.js";
12
+ import { describeError, MAX_PROVIDER_RESPONSE_BYTES, PROVIDER_REQUEST_TIMEOUT_MS, sanitizeProviderText, } from "./_shared.js";
13
13
  import { ExternalAuthError } from "./errors.js";
14
14
  /** Response bytes accepted from a token endpoint before the exchange is refused. */
15
15
  export const MAX_TOKEN_RESPONSE_BYTES = MAX_PROVIDER_RESPONSE_BYTES;
16
- /** Wall-clock budget for the exchange, covering the connect and the body read. */
17
- export const TOKEN_EXCHANGE_TIMEOUT_MS = 10_000;
18
16
  /**
19
17
  * Posts the authorization code and returns a token response known to be a JSON
20
18
  * object carrying `requiredField`. Every failure throws
@@ -34,7 +32,7 @@ export async function runTokenExchange(input) {
34
32
  },
35
33
  body: input.body,
36
34
  redirect: "manual",
37
- signal: packageDeadlineSignal(AbortSignal.timeout(input.timeoutMs ?? TOKEN_EXCHANGE_TIMEOUT_MS)),
35
+ signal: packageDeadlineSignal(AbortSignal.timeout(input.timeoutMs ?? PROVIDER_REQUEST_TIMEOUT_MS)),
38
36
  });
39
37
  }
40
38
  catch (error) {
@@ -17,8 +17,8 @@
17
17
  */
18
18
  import { toArrayBuffer } from "../../utils/_buffer.js";
19
19
  import { base64urlDecode } from "../../utils/crypto.js";
20
- import { defaultFetch } from "../../utils/_default-fetch.js";
21
- import { assertIdTokenClaims, describeError, readProviderText, } from "./_shared.js";
20
+ import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
21
+ import { assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, } from "./_shared.js";
22
22
  import { runTokenExchange } from "./_token-exchange.js";
23
23
  import { createAppleClientSecretFactory, } from "./apple-client-secret.js";
24
24
  import { ExternalAuthError } from "./errors.js";
@@ -101,6 +101,7 @@ export function appleProvider(options) {
101
101
  try {
102
102
  res = await fetchImpl(APPLE_JWKS_URL, {
103
103
  headers: { Accept: "application/json" },
104
+ signal: packageDeadlineSignal(AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS)),
104
105
  });
105
106
  }
106
107
  catch (error) {
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * @module
6
6
  */
7
- import { defaultFetch } from "../../utils/_default-fetch.js";
8
- import { describeError, readProviderText } from "./_shared.js";
7
+ import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
8
+ import { describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, } from "./_shared.js";
9
9
  import { runTokenExchange } from "./_token-exchange.js";
10
10
  import { ExternalAuthError } from "./errors.js";
11
11
  const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize";
@@ -65,6 +65,7 @@ export function githubProvider(options) {
65
65
  Authorization: `Bearer ${accessToken}`,
66
66
  Accept: "application/vnd.github+json",
67
67
  },
68
+ signal: packageDeadlineSignal(AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS)),
68
69
  });
69
70
  }
70
71
  catch (error) {
@@ -7,9 +7,9 @@
7
7
  *
8
8
  * @module
9
9
  */
10
- import { defaultFetch } from "../../utils/_default-fetch.js";
10
+ import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
11
11
  import { generateCodeChallenge } from "../../utils/pkce.js";
12
- import { describeError, readErrorBody, readProviderText } from "./_shared.js";
12
+ import { describeError, PROVIDER_REQUEST_TIMEOUT_MS, readErrorBody, readProviderText, } from "./_shared.js";
13
13
  import { runTokenExchange } from "./_token-exchange.js";
14
14
  import { ExternalAuthError } from "./errors.js";
15
15
  /**
@@ -73,6 +73,7 @@ export function oauth2Provider(options) {
73
73
  Authorization: `Bearer ${accessToken}`,
74
74
  Accept: "application/json",
75
75
  },
76
+ signal: packageDeadlineSignal(AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS)),
76
77
  });
77
78
  }
78
79
  catch (error) {
@@ -7,9 +7,9 @@
7
7
  import { DirectClient } from "../../client/direct-client.js";
8
8
  import { MemoryAuthRequestStorage } from "../../client/storage.js";
9
9
  import { isOAuth2Error } from "../../errors.js";
10
- import { defaultFetch } from "../../utils/_default-fetch.js";
10
+ import { defaultFetch, packageDeadlineSignal, } from "../../utils/_default-fetch.js";
11
11
  import { generateCodeChallenge } from "../../utils/pkce.js";
12
- import { assertIdTokenClaims, describeError, readProviderText, stripTrailingSlash, } from "./_shared.js";
12
+ import { assertIdTokenClaims, describeError, PROVIDER_REQUEST_TIMEOUT_MS, readProviderText, stripTrailingSlash, } from "./_shared.js";
13
13
  import { ExternalAuthError } from "./errors.js";
14
14
  /**
15
15
  * Creates a connector for any spec-compliant OpenID Connect provider.
@@ -148,6 +148,7 @@ export function oidcProvider(options) {
148
148
  Authorization: `Bearer ${accessToken}`,
149
149
  Accept: "application/json",
150
150
  },
151
+ signal: packageDeadlineSignal(AbortSignal.timeout(PROVIDER_REQUEST_TIMEOUT_MS)),
151
152
  });
152
153
  if (!res.ok) {
153
154
  await res.body?.cancel();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
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",