@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.
- package/dist/adapters/hono/bff/bff.d.ts +7 -3
- package/dist/adapters/hono/bff/bff.js +29 -2
- package/dist/adapters/hono/bff/mod.d.ts +4 -3
- package/dist/adapters/hono/bff/mod.js +3 -2
- package/dist/adapters/hono/bff/session-store.d.ts +55 -1
- package/dist/adapters/hono/bff/session-store.js +24 -0
- package/dist/adapters/hono/bff/testing.d.ts +8 -1
- package/dist/adapters/hono/bff/testing.js +116 -2
- package/dist/identity/external/_shared.d.ts +8 -0
- package/dist/identity/external/_shared.js +8 -0
- package/dist/identity/external/_token-exchange.d.ts +1 -3
- package/dist/identity/external/_token-exchange.js +2 -4
- package/dist/identity/external/apple.js +3 -2
- package/dist/identity/external/github.js +3 -2
- package/dist/identity/external/oauth2.js +3 -2
- package/dist/identity/external/oidc.js +3 -2
- package/package.json +1 -1
|
@@ -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.
|
|
325
|
-
* `
|
|
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.#
|
|
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
|
|
16
|
-
* `runSessionStoreContractTests` from
|
|
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
|
|
16
|
-
* `runSessionStoreContractTests` from
|
|
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
|
|
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 ??
|
|
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.
|
|
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",
|