@udibo/oauth2 0.20.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/cli/idp/server.js +19 -4
- package/dist/identity/external/_shared.d.ts +17 -2
- package/dist/identity/external/_shared.js +35 -3
- package/dist/identity/external/_token-exchange.js +2 -2
- 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 +2 -2
- package/dist/identity/hibp.d.ts +10 -0
- package/dist/identity/hibp.js +11 -1
- package/dist/server/_response-json.d.ts +16 -0
- package/dist/server/_response-json.js +35 -0
- package/dist/server/introspection-token-reader.d.ts +17 -5
- package/dist/server/introspection-token-reader.js +16 -15
- package/dist/server/jwks-token-reader.d.ts +16 -5
- package/dist/server/jwks-token-reader.js +15 -11
- package/dist/utils/_body.d.ts +20 -9
- package/dist/utils/_body.js +19 -8
- 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);
|
package/dist/cli/idp/server.js
CHANGED
|
@@ -23,6 +23,7 @@ import { PasswordGrant } from "../../server/grants/password.js";
|
|
|
23
23
|
import { RefreshTokenGrant } from "../../server/grants/refresh-token.js";
|
|
24
24
|
import { StaticSigningKeyProvider, } from "../../server/signing-keys.js";
|
|
25
25
|
import { generateCodeChallenge, generateCodeVerifier, generateState, } from "../../utils/pkce.js";
|
|
26
|
+
import { DEFAULT_MAX_BODY_BYTES, readBoundedBody } from "../../utils/_body.js";
|
|
26
27
|
import { timingSafeEqualString } from "../../utils/crypto.js";
|
|
27
28
|
import { safeReturnTo } from "../../utils/url.js";
|
|
28
29
|
import { MemoryAuthorizationCodeService, MemoryClientService, MemoryTokenService, MemoryUserService, } from "../../testing/services.js";
|
|
@@ -218,8 +219,22 @@ function handleConsentFor(state, request) {
|
|
|
218
219
|
}));
|
|
219
220
|
};
|
|
220
221
|
}
|
|
222
|
+
class BodyTooLargeError extends Error {
|
|
223
|
+
}
|
|
224
|
+
async function readBody(request) {
|
|
225
|
+
const bytes = await readBoundedBody(request, DEFAULT_MAX_BODY_BYTES);
|
|
226
|
+
if (!bytes) {
|
|
227
|
+
throw new BodyTooLargeError(`request body exceeds ${DEFAULT_MAX_BODY_BYTES} bytes`);
|
|
228
|
+
}
|
|
229
|
+
return bytes;
|
|
230
|
+
}
|
|
231
|
+
async function readFormBody(request) {
|
|
232
|
+
return await new Response(await readBody(request), {
|
|
233
|
+
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
|
234
|
+
}).formData();
|
|
235
|
+
}
|
|
221
236
|
async function handleLoginSubmission(state, config, request) {
|
|
222
|
-
const form = await request
|
|
237
|
+
const form = await readFormBody(request);
|
|
223
238
|
const returnTo = safeReturnTo(formValue(form, "return_to"));
|
|
224
239
|
const quickSignIn = formValue(form, "as");
|
|
225
240
|
let user;
|
|
@@ -252,7 +267,7 @@ async function handleConsentSubmission(state, request) {
|
|
|
252
267
|
const user = await currentUser(state, request);
|
|
253
268
|
if (!user)
|
|
254
269
|
return redirect("/login");
|
|
255
|
-
const form = await request
|
|
270
|
+
const form = await readFormBody(request);
|
|
256
271
|
const returnTo = safeReturnTo(formValue(form, "return_to"));
|
|
257
272
|
const target = new URL(returnTo, request.url);
|
|
258
273
|
const clientId = target.searchParams.get("client_id");
|
|
@@ -294,7 +309,7 @@ function handleLogout(state, config, request) {
|
|
|
294
309
|
});
|
|
295
310
|
}
|
|
296
311
|
async function readJsonBody(request) {
|
|
297
|
-
const text = await request
|
|
312
|
+
const text = new TextDecoder().decode(await readBody(request));
|
|
298
313
|
if (text.trim().length === 0)
|
|
299
314
|
return {};
|
|
300
315
|
const parsed = JSON.parse(text);
|
|
@@ -522,7 +537,7 @@ export async function startDevIdentityProvider(options) {
|
|
|
522
537
|
}
|
|
523
538
|
}
|
|
524
539
|
catch (error) {
|
|
525
|
-
return jsonError(400, error instanceof Error ? error.message : String(error));
|
|
540
|
+
return jsonError(error instanceof BodyTooLargeError ? 413 : 400, error instanceof Error ? error.message : String(error));
|
|
526
541
|
}
|
|
527
542
|
};
|
|
528
543
|
const server = serveNode({
|
|
@@ -24,10 +24,25 @@ 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
|
+
* Response bytes a connector reads from a provider endpoint — token, profile,
|
|
29
|
+
* UserInfo, or key set — before refusing the response.
|
|
30
|
+
*/
|
|
31
|
+
export declare const MAX_PROVIDER_RESPONSE_BYTES: number;
|
|
32
|
+
/**
|
|
33
|
+
* Reads `res`'s body as text, stopping and cancelling it past
|
|
34
|
+
* {@link MAX_PROVIDER_RESPONSE_BYTES}. `source` names the endpoint in the
|
|
35
|
+
* error message, e.g. `"the Acme profile endpoint"`.
|
|
36
|
+
*
|
|
37
|
+
* @throws {ExternalAuthError} `provider_error` when the body is larger than
|
|
38
|
+
* the cap or stops arriving.
|
|
39
|
+
*/
|
|
40
|
+
export declare function readProviderText(res: Response, provider: string, source: string): Promise<string>;
|
|
27
41
|
/**
|
|
28
42
|
* Reads a failed response's body into a bounded, single-line `": <text>"`
|
|
29
|
-
* suffix to append to an error message, or `""` when the body is empty
|
|
30
|
-
* unreadable
|
|
43
|
+
* suffix to append to an error message, or `""` when the body is empty,
|
|
44
|
+
* unreadable, or larger than {@link MAX_PROVIDER_RESPONSE_BYTES}. Consumes
|
|
45
|
+
* the body, so the caller must not read it again.
|
|
31
46
|
*/
|
|
32
47
|
export declare function readErrorBody(res: Response): Promise<string>;
|
|
33
48
|
/**
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import { isOAuth2Error } from "../../errors.js";
|
|
12
|
+
import { readBoundedBody } from "../../utils/_body.js";
|
|
12
13
|
import { timingSafeEqualString } from "../../utils/crypto.js";
|
|
13
14
|
import { PROVIDER_TEXT_MAX_LENGTH, sanitizeProviderText, } from "../../utils/text.js";
|
|
14
15
|
import { ExternalAuthError } from "./errors.js";
|
|
@@ -36,14 +37,45 @@ export function describeError(error) {
|
|
|
36
37
|
}
|
|
37
38
|
return sanitizeProviderText(error instanceof Error ? error.message : String(error));
|
|
38
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Response bytes a connector reads from a provider endpoint — token, profile,
|
|
42
|
+
* UserInfo, or key set — before refusing the response.
|
|
43
|
+
*/
|
|
44
|
+
export const MAX_PROVIDER_RESPONSE_BYTES = 64 * 1024;
|
|
45
|
+
/**
|
|
46
|
+
* Reads `res`'s body as text, stopping and cancelling it past
|
|
47
|
+
* {@link MAX_PROVIDER_RESPONSE_BYTES}. `source` names the endpoint in the
|
|
48
|
+
* error message, e.g. `"the Acme profile endpoint"`.
|
|
49
|
+
*
|
|
50
|
+
* @throws {ExternalAuthError} `provider_error` when the body is larger than
|
|
51
|
+
* the cap or stops arriving.
|
|
52
|
+
*/
|
|
53
|
+
export async function readProviderText(res, provider, source) {
|
|
54
|
+
let bytes;
|
|
55
|
+
try {
|
|
56
|
+
bytes = await readBoundedBody(res, MAX_PROVIDER_RESPONSE_BYTES);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
throw new ExternalAuthError(provider, "provider_error", `${source} response could not be read (${describeError(error)}).`, { cause: error });
|
|
60
|
+
}
|
|
61
|
+
if (!bytes) {
|
|
62
|
+
throw new ExternalAuthError(provider, "provider_error", `${source} response exceeded ${MAX_PROVIDER_RESPONSE_BYTES} bytes — ` +
|
|
63
|
+
`refusing to parse a response this large.`);
|
|
64
|
+
}
|
|
65
|
+
return new TextDecoder().decode(bytes);
|
|
66
|
+
}
|
|
39
67
|
/**
|
|
40
68
|
* Reads a failed response's body into a bounded, single-line `": <text>"`
|
|
41
|
-
* suffix to append to an error message, or `""` when the body is empty
|
|
42
|
-
* unreadable
|
|
69
|
+
* suffix to append to an error message, or `""` when the body is empty,
|
|
70
|
+
* unreadable, or larger than {@link MAX_PROVIDER_RESPONSE_BYTES}. Consumes
|
|
71
|
+
* the body, so the caller must not read it again.
|
|
43
72
|
*/
|
|
44
73
|
export async function readErrorBody(res) {
|
|
45
74
|
try {
|
|
46
|
-
const
|
|
75
|
+
const bytes = await readBoundedBody(res, MAX_PROVIDER_RESPONSE_BYTES);
|
|
76
|
+
if (!bytes)
|
|
77
|
+
return "";
|
|
78
|
+
const snippet = sanitizeProviderText(new TextDecoder().decode(bytes));
|
|
47
79
|
return snippet ? `: ${snippet}` : "";
|
|
48
80
|
}
|
|
49
81
|
catch {
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* @module
|
|
10
10
|
*/
|
|
11
11
|
import { packageDeadlineSignal } from "../../utils/_default-fetch.js";
|
|
12
|
-
import { describeError, sanitizeProviderText } from "./_shared.js";
|
|
12
|
+
import { describeError, MAX_PROVIDER_RESPONSE_BYTES, 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
|
-
export const MAX_TOKEN_RESPONSE_BYTES =
|
|
15
|
+
export const MAX_TOKEN_RESPONSE_BYTES = MAX_PROVIDER_RESPONSE_BYTES;
|
|
16
16
|
/** Wall-clock budget for the exchange, covering the connect and the body read. */
|
|
17
17
|
export const TOKEN_EXCHANGE_TIMEOUT_MS = 10_000;
|
|
18
18
|
/**
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { toArrayBuffer } from "../../utils/_buffer.js";
|
|
19
19
|
import { base64urlDecode } from "../../utils/crypto.js";
|
|
20
20
|
import { defaultFetch } from "../../utils/_default-fetch.js";
|
|
21
|
-
import { assertIdTokenClaims, describeError } from "./_shared.js";
|
|
21
|
+
import { assertIdTokenClaims, describeError, 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";
|
|
@@ -110,9 +110,10 @@ export function appleProvider(options) {
|
|
|
110
110
|
await res.body?.cancel();
|
|
111
111
|
throw new ExternalAuthError(id, "provider_error", `Apple's JWKS endpoint responded with HTTP ${res.status}.`);
|
|
112
112
|
}
|
|
113
|
+
const text = await readProviderText(res, id, "Apple's JWKS endpoint");
|
|
113
114
|
let doc;
|
|
114
115
|
try {
|
|
115
|
-
doc =
|
|
116
|
+
doc = JSON.parse(text);
|
|
116
117
|
}
|
|
117
118
|
catch (error) {
|
|
118
119
|
throw new ExternalAuthError(id, "provider_error", `Apple's JWKS endpoint returned invalid JSON (${describeError(error)}).`, { cause: error });
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* @module
|
|
6
6
|
*/
|
|
7
7
|
import { defaultFetch } from "../../utils/_default-fetch.js";
|
|
8
|
-
import { describeError } from "./_shared.js";
|
|
8
|
+
import { describeError, 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";
|
|
@@ -74,8 +74,9 @@ export function githubProvider(options) {
|
|
|
74
74
|
await res.body?.cancel();
|
|
75
75
|
return null;
|
|
76
76
|
}
|
|
77
|
+
const text = await readProviderText(res, id, url);
|
|
77
78
|
try {
|
|
78
|
-
return
|
|
79
|
+
return JSON.parse(text);
|
|
79
80
|
}
|
|
80
81
|
catch (error) {
|
|
81
82
|
throw new ExternalAuthError(id, "provider_error", `${url} returned a response that was not valid JSON ` +
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { defaultFetch } from "../../utils/_default-fetch.js";
|
|
11
11
|
import { generateCodeChallenge } from "../../utils/pkce.js";
|
|
12
|
-
import { describeError, readErrorBody } from "./_shared.js";
|
|
12
|
+
import { describeError, readErrorBody, readProviderText } from "./_shared.js";
|
|
13
13
|
import { runTokenExchange } from "./_token-exchange.js";
|
|
14
14
|
import { ExternalAuthError } from "./errors.js";
|
|
15
15
|
/**
|
|
@@ -85,8 +85,9 @@ export function oauth2Provider(options) {
|
|
|
85
85
|
`access token was issued but lacks profile access — ensure the ` +
|
|
86
86
|
`identity scope is requested.`);
|
|
87
87
|
}
|
|
88
|
+
const text = await readProviderText(res, id, `the ${options.displayName} profile endpoint`);
|
|
88
89
|
try {
|
|
89
|
-
return
|
|
90
|
+
return JSON.parse(text);
|
|
90
91
|
}
|
|
91
92
|
catch (error) {
|
|
92
93
|
throw new ExternalAuthError(id, "provider_error", `the ${options.displayName} profile endpoint returned a response ` +
|
|
@@ -9,7 +9,7 @@ import { MemoryAuthRequestStorage } from "../../client/storage.js";
|
|
|
9
9
|
import { isOAuth2Error } from "../../errors.js";
|
|
10
10
|
import { defaultFetch } from "../../utils/_default-fetch.js";
|
|
11
11
|
import { generateCodeChallenge } from "../../utils/pkce.js";
|
|
12
|
-
import { assertIdTokenClaims, describeError, stripTrailingSlash, } from "./_shared.js";
|
|
12
|
+
import { assertIdTokenClaims, describeError, 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.
|
|
@@ -153,7 +153,7 @@ export function oidcProvider(options) {
|
|
|
153
153
|
await res.body?.cancel();
|
|
154
154
|
return null;
|
|
155
155
|
}
|
|
156
|
-
const info = (await res
|
|
156
|
+
const info = JSON.parse(await readProviderText(res, id, "the UserInfo endpoint"));
|
|
157
157
|
return info.sub === idTokenSub ? info : null;
|
|
158
158
|
}
|
|
159
159
|
catch {
|
package/dist/identity/hibp.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ export interface BreachedPasswordOptions {
|
|
|
32
32
|
failOpen?: boolean;
|
|
33
33
|
/** Abort the range request after this many ms. Defaults to 3000. */
|
|
34
34
|
timeoutMs?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Largest range response, in bytes, the check reads. Defaults to 1048576
|
|
37
|
+
* (1 MiB), about ten times a padded range. The read counts bytes as they
|
|
38
|
+
* arrive and cancels the response at the cap, whether or not it has a
|
|
39
|
+
* `Content-Length`; a larger response counts as the range API being
|
|
40
|
+
* unavailable, so `failOpen` decides the outcome.
|
|
41
|
+
*/
|
|
42
|
+
maxResponseBytes?: number;
|
|
35
43
|
/** Fetch implementation — inject for tests. Defaults to `globalThis.fetch`. */
|
|
36
44
|
fetch?: typeof fetch;
|
|
37
45
|
/** Range API base URL. Defaults to `https://api.pwnedpasswords.com/range/`. */
|
|
@@ -56,6 +64,8 @@ export declare function sha1Hex(input: string): Promise<string>;
|
|
|
56
64
|
* Build a {@link PasswordPolicy} validator that rejects passwords found in the
|
|
57
65
|
* HIBP breach corpus.
|
|
58
66
|
*
|
|
67
|
+
* @throws {RangeError} If `maxResponseBytes` is not a positive integer.
|
|
68
|
+
*
|
|
59
69
|
* @example
|
|
60
70
|
* ```ts
|
|
61
71
|
* const policy: PasswordPolicy = {
|
package/dist/identity/hibp.js
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
*
|
|
18
18
|
* @module
|
|
19
19
|
*/
|
|
20
|
+
import { assertMaxBodyBytes, readBoundedBody } from "../utils/_body.js";
|
|
20
21
|
import { encodeHex } from "../utils/_encoding.js";
|
|
21
22
|
import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
|
|
22
23
|
import { dispatchIdentityEvent } from "./events.js";
|
|
23
24
|
const DEFAULT_BASE_URL = "https://api.pwnedpasswords.com/range/";
|
|
25
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
24
26
|
const VALIDATOR_NAME = "breached_password";
|
|
25
27
|
const BREACHED_MESSAGE = "This password has appeared in a data breach; choose a different one.";
|
|
26
28
|
const UNAVAILABLE_MESSAGE = "The password couldn't be checked against known breaches; try again.";
|
|
@@ -33,6 +35,8 @@ export async function sha1Hex(input) {
|
|
|
33
35
|
* Build a {@link PasswordPolicy} validator that rejects passwords found in the
|
|
34
36
|
* HIBP breach corpus.
|
|
35
37
|
*
|
|
38
|
+
* @throws {RangeError} If `maxResponseBytes` is not a positive integer.
|
|
39
|
+
*
|
|
36
40
|
* @example
|
|
37
41
|
* ```ts
|
|
38
42
|
* const policy: PasswordPolicy = {
|
|
@@ -45,6 +49,8 @@ export function breachedPasswordValidator(options = {}) {
|
|
|
45
49
|
const threshold = options.threshold ?? 1;
|
|
46
50
|
const failOpen = options.failOpen ?? true;
|
|
47
51
|
const timeoutMs = options.timeoutMs ?? 3000;
|
|
52
|
+
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
53
|
+
assertMaxBodyBytes(maxResponseBytes, "maxResponseBytes");
|
|
48
54
|
const fetchImpl = options.fetch ?? defaultFetch;
|
|
49
55
|
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
50
56
|
const onEvent = options.onEvent;
|
|
@@ -61,7 +67,11 @@ export function breachedPasswordValidator(options = {}) {
|
|
|
61
67
|
await response.body?.cancel();
|
|
62
68
|
throw new Error(`range API responded ${response.status}`);
|
|
63
69
|
}
|
|
64
|
-
const
|
|
70
|
+
const bytes = await readBoundedBody(response, maxResponseBytes);
|
|
71
|
+
if (!bytes) {
|
|
72
|
+
throw new Error(`range API response exceeded ${maxResponseBytes} bytes`);
|
|
73
|
+
}
|
|
74
|
+
const body = new TextDecoder().decode(bytes);
|
|
65
75
|
for (const line of body.split("\n")) {
|
|
66
76
|
const [lineSuffix, countText] = line.trim().split(":");
|
|
67
77
|
if (lineSuffix === suffix && Number(countText) >= threshold) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Not part of the public API. The bounded JSON read the token readers apply
|
|
3
|
+
* to the responses of the authorization server they call.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Parses `response`'s body as JSON, reading at most `maxBytes`. `label`
|
|
9
|
+
* names the response in error messages.
|
|
10
|
+
*
|
|
11
|
+
* @throws {ServerError} When the body exceeds `maxBytes` (the read stops and
|
|
12
|
+
* the body is cancelled) or is not JSON.
|
|
13
|
+
* @throws {TemporarilyUnavailableError} When the body stops arriving before it
|
|
14
|
+
* is complete: the deadline fired or the connection dropped mid-body.
|
|
15
|
+
*/
|
|
16
|
+
export declare function readResponseJson(response: Response, maxBytes: number, label: string): Promise<unknown>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Not part of the public API. The bounded JSON read the token readers apply
|
|
3
|
+
* to the responses of the authorization server they call.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { ServerError, TemporarilyUnavailableError } from "../errors.js";
|
|
8
|
+
import { readBoundedBody } from "../utils/_body.js";
|
|
9
|
+
/**
|
|
10
|
+
* Parses `response`'s body as JSON, reading at most `maxBytes`. `label`
|
|
11
|
+
* names the response in error messages.
|
|
12
|
+
*
|
|
13
|
+
* @throws {ServerError} When the body exceeds `maxBytes` (the read stops and
|
|
14
|
+
* the body is cancelled) or is not JSON.
|
|
15
|
+
* @throws {TemporarilyUnavailableError} When the body stops arriving before it
|
|
16
|
+
* is complete: the deadline fired or the connection dropped mid-body.
|
|
17
|
+
*/
|
|
18
|
+
export async function readResponseJson(response, maxBytes, label) {
|
|
19
|
+
let bytes;
|
|
20
|
+
try {
|
|
21
|
+
bytes = await readBoundedBody(response, maxBytes);
|
|
22
|
+
}
|
|
23
|
+
catch (cause) {
|
|
24
|
+
throw new TemporarilyUnavailableError(`${label} request failed`, { cause });
|
|
25
|
+
}
|
|
26
|
+
if (!bytes) {
|
|
27
|
+
throw new ServerError(`${label} response exceeded ${maxBytes} bytes`);
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(new TextDecoder().decode(bytes));
|
|
31
|
+
}
|
|
32
|
+
catch (cause) {
|
|
33
|
+
throw new ServerError(`${label} response was not JSON`, { cause });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -67,6 +67,13 @@ export interface IntrospectionTokenReaderOptions<Client extends ClientInterface,
|
|
|
67
67
|
* {@linkcode fetch} that doesn't honour `AbortSignal`.
|
|
68
68
|
*/
|
|
69
69
|
fetchTimeoutMs?: number;
|
|
70
|
+
/**
|
|
71
|
+
* Largest introspection response, in bytes, the reader accepts. Defaults to
|
|
72
|
+
* 65536 (64 KiB). The read counts bytes as they arrive and cancels the
|
|
73
|
+
* response at the cap, whether or not it has a `Content-Length`; a larger
|
|
74
|
+
* response throws {@linkcode ServerError}.
|
|
75
|
+
*/
|
|
76
|
+
maxResponseBytes?: number;
|
|
70
77
|
/**
|
|
71
78
|
* `fetch` implementation used to call the introspection endpoint. Defaults
|
|
72
79
|
* to the global `fetch`. Inject it to route through a custom client (mTLS,
|
|
@@ -127,10 +134,14 @@ export declare class IntrospectionTokenReader<Client extends ClientInterface, Us
|
|
|
127
134
|
private getClient;
|
|
128
135
|
private getUser?;
|
|
129
136
|
private fetchTimeoutMs;
|
|
137
|
+
private maxResponseBytes;
|
|
130
138
|
private customFetch?;
|
|
131
139
|
/**
|
|
132
|
-
* `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000,
|
|
133
|
-
* `fetch` to the global `fetch` when
|
|
140
|
+
* `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000,
|
|
141
|
+
* `maxResponseBytes` to 65536, and `fetch` to the global `fetch` when
|
|
142
|
+
* omitted from `options`.
|
|
143
|
+
*
|
|
144
|
+
* @throws {RangeError} If `maxResponseBytes` is not a positive integer.
|
|
134
145
|
*/
|
|
135
146
|
constructor(options: IntrospectionTokenReaderOptions<Client, User, S>);
|
|
136
147
|
/**
|
|
@@ -149,9 +160,10 @@ export declare class IntrospectionTokenReader<Client extends ClientInterface, Us
|
|
|
149
160
|
* a genuinely invalid token (which is `undefined` → `invalid_token`), not
|
|
150
161
|
* collapsed into it.
|
|
151
162
|
* @throws {ServerError} If the endpoint returns any other non-2xx status
|
|
152
|
-
* (e.g. the reader's own introspection credentials are rejected)
|
|
153
|
-
* that is not JSON
|
|
154
|
-
*
|
|
163
|
+
* (e.g. the reader's own introspection credentials are rejected), a body
|
|
164
|
+
* that is not JSON, or one larger than
|
|
165
|
+
* {@linkcode IntrospectionTokenReaderOptions.maxResponseBytes} — a
|
|
166
|
+
* misconfiguration, not a verdict on the caller's token.
|
|
155
167
|
*
|
|
156
168
|
* An active response is only accepted when its `token_type` names a bearer
|
|
157
169
|
* token. RFC 7662 answers for refresh tokens too, and a refresh token's
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import { ServerError, TemporarilyUnavailableError } from "../errors.js";
|
|
13
13
|
import { BasicScope } from "../models/scope.js";
|
|
14
14
|
import { encodeBasicAuth } from "../utils/basic-auth.js";
|
|
15
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_RESPONSE_BYTES, } from "../utils/_body.js";
|
|
16
|
+
import { readResponseJson } from "./_response-json.js";
|
|
15
17
|
import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
|
|
16
18
|
/**
|
|
17
19
|
* Validates tokens by calling an OAuth2 introspection endpoint (RFC 7662).
|
|
@@ -62,10 +64,14 @@ export class IntrospectionTokenReader {
|
|
|
62
64
|
getClient;
|
|
63
65
|
getUser;
|
|
64
66
|
fetchTimeoutMs;
|
|
67
|
+
maxResponseBytes;
|
|
65
68
|
customFetch;
|
|
66
69
|
/**
|
|
67
|
-
* `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000,
|
|
68
|
-
* `fetch` to the global `fetch` when
|
|
70
|
+
* `Scope` defaults to {@linkcode BasicScope}, `fetchTimeoutMs` to 5000,
|
|
71
|
+
* `maxResponseBytes` to 65536, and `fetch` to the global `fetch` when
|
|
72
|
+
* omitted from `options`.
|
|
73
|
+
*
|
|
74
|
+
* @throws {RangeError} If `maxResponseBytes` is not a positive integer.
|
|
69
75
|
*/
|
|
70
76
|
constructor(options) {
|
|
71
77
|
this.introspectionEndpoint = options.introspectionEndpoint;
|
|
@@ -76,6 +82,9 @@ export class IntrospectionTokenReader {
|
|
|
76
82
|
this.getClient = options.getClient;
|
|
77
83
|
this.getUser = options.getUser;
|
|
78
84
|
this.fetchTimeoutMs = options.fetchTimeoutMs ?? 5_000;
|
|
85
|
+
this.maxResponseBytes =
|
|
86
|
+
options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
87
|
+
assertMaxBodyBytes(this.maxResponseBytes, "maxResponseBytes");
|
|
79
88
|
this.customFetch = options.fetch;
|
|
80
89
|
}
|
|
81
90
|
/**
|
|
@@ -94,9 +103,10 @@ export class IntrospectionTokenReader {
|
|
|
94
103
|
* a genuinely invalid token (which is `undefined` → `invalid_token`), not
|
|
95
104
|
* collapsed into it.
|
|
96
105
|
* @throws {ServerError} If the endpoint returns any other non-2xx status
|
|
97
|
-
* (e.g. the reader's own introspection credentials are rejected)
|
|
98
|
-
* that is not JSON
|
|
99
|
-
*
|
|
106
|
+
* (e.g. the reader's own introspection credentials are rejected), a body
|
|
107
|
+
* that is not JSON, or one larger than
|
|
108
|
+
* {@linkcode IntrospectionTokenReaderOptions.maxResponseBytes} — a
|
|
109
|
+
* misconfiguration, not a verdict on the caller's token.
|
|
100
110
|
*
|
|
101
111
|
* An active response is only accepted when its `token_type` names a bearer
|
|
102
112
|
* token. RFC 7662 answers for refresh tokens too, and a refresh token's
|
|
@@ -153,16 +163,7 @@ export class IntrospectionTokenReader {
|
|
|
153
163
|
? new TemporarilyUnavailableError(`token introspection failed (HTTP ${response.status})`)
|
|
154
164
|
: new ServerError(`token introspection failed (HTTP ${response.status})`);
|
|
155
165
|
}
|
|
156
|
-
|
|
157
|
-
return (await response.json());
|
|
158
|
-
}
|
|
159
|
-
catch (cause) {
|
|
160
|
-
throw controller.signal.aborted
|
|
161
|
-
? new TemporarilyUnavailableError("token introspection request failed", { cause })
|
|
162
|
-
: new ServerError("token introspection response was not JSON", {
|
|
163
|
-
cause,
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
+
return (await readResponseJson(response, this.maxResponseBytes, "token introspection"));
|
|
166
167
|
}
|
|
167
168
|
finally {
|
|
168
169
|
clearTimeout(deadline);
|
|
@@ -146,6 +146,14 @@ export interface JwksTokenReaderOptions<Client extends ClientInterface, User, S
|
|
|
146
146
|
* {@linkcode fetch} that doesn't honour `AbortSignal`.
|
|
147
147
|
*/
|
|
148
148
|
fetchTimeoutMs?: number;
|
|
149
|
+
/**
|
|
150
|
+
* Largest discovery document or JWKS, in bytes, the reader accepts.
|
|
151
|
+
* Defaults to 262144 (256 KiB). The read counts bytes as they arrive and
|
|
152
|
+
* cancels the response at the cap, whether or not it has a
|
|
153
|
+
* `Content-Length`; a larger response fails the refresh with a
|
|
154
|
+
* {@linkcode ServerError}, as a malformed one does.
|
|
155
|
+
*/
|
|
156
|
+
maxResponseBytes?: number;
|
|
149
157
|
/** Optional scope constructor for parsing scope strings. Defaults to BasicScope. */
|
|
150
158
|
Scope?: ScopeConstructor<S>;
|
|
151
159
|
/**
|
|
@@ -233,14 +241,15 @@ export declare class JwksTokenReader<Client extends ClientInterface, User, S ext
|
|
|
233
241
|
* Defaults: every Web Crypto asymmetric algorithm accepted, `at+jwt`
|
|
234
242
|
* required as `typ`, `client_id` as the required client-id claim, 30
|
|
235
243
|
* seconds of clock skew, a 10-minute cache with a 30-second minimum
|
|
236
|
-
* between fetches, a 5-second request deadline,
|
|
237
|
-
* and the global `fetch`.
|
|
244
|
+
* between fetches, a 5-second request deadline, a 256 KiB response cap,
|
|
245
|
+
* {@linkcode BasicScope}, and the global `fetch`.
|
|
238
246
|
*
|
|
239
247
|
* @throws {TypeError} If `issuer` is not an absolute URL, `audience` is
|
|
240
248
|
* empty, or `algorithms` names an algorithm this package cannot verify
|
|
241
249
|
* (a silently ignored entry would read as a policy that isn't enforced).
|
|
242
250
|
* @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
|
|
243
|
-
* overflows when converted to milliseconds
|
|
251
|
+
* overflows when converted to milliseconds, or `maxResponseBytes` is not
|
|
252
|
+
* a positive integer.
|
|
244
253
|
*/
|
|
245
254
|
constructor(options: JwksTokenReaderOptions<Client, User, S>);
|
|
246
255
|
/**
|
|
@@ -263,8 +272,10 @@ export declare class JwksTokenReader<Client extends ClientInterface, User, S ext
|
|
|
263
272
|
* invalid token, not collapsed into it. Once keys are cached, a failed
|
|
264
273
|
* refresh is absorbed and the cached keys keep serving.
|
|
265
274
|
* @throws {ServerError} If, with no keys cached, the JWKS endpoint returns
|
|
266
|
-
* a 4xx or a body that is not a JWKS,
|
|
267
|
-
*
|
|
275
|
+
* a 4xx or a body that is not a JWKS, either document exceeds
|
|
276
|
+
* {@linkcode JwksTokenReaderOptions.maxResponseBytes}, or discovery
|
|
277
|
+
* reports no `jwks_uri` — a misconfiguration, not a verdict on the
|
|
278
|
+
* caller's token.
|
|
268
279
|
*/
|
|
269
280
|
getToken(accessToken: string): Promise<Token<Client, User, S> | undefined>;
|
|
270
281
|
}
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { ServerError, TemporarilyUnavailableError } from "../errors.js";
|
|
16
16
|
import { BasicScope } from "../models/scope.js";
|
|
17
|
+
import { assertMaxBodyBytes, DEFAULT_MAX_KEY_SET_BYTES, } from "../utils/_body.js";
|
|
17
18
|
import { toArrayBuffer } from "../utils/_buffer.js";
|
|
18
19
|
import { base64urlDecode } from "../utils/crypto.js";
|
|
19
20
|
import { defaultFetch, packageDeadlineSignal, } from "../utils/_default-fetch.js";
|
|
20
21
|
import { assertClockSkewSeconds } from "./_clock-skew.js";
|
|
22
|
+
import { readResponseJson } from "./_response-json.js";
|
|
21
23
|
const ALGORITHMS = {
|
|
22
24
|
ES256: {
|
|
23
25
|
kty: "EC",
|
|
@@ -210,6 +212,7 @@ export class JwksTokenReader {
|
|
|
210
212
|
#cacheMaxAgeMs;
|
|
211
213
|
#minFetchIntervalMs;
|
|
212
214
|
#fetchTimeoutMs;
|
|
215
|
+
#maxResponseBytes;
|
|
213
216
|
#metadataUrls;
|
|
214
217
|
#Scope;
|
|
215
218
|
#getClient;
|
|
@@ -224,14 +227,15 @@ export class JwksTokenReader {
|
|
|
224
227
|
* Defaults: every Web Crypto asymmetric algorithm accepted, `at+jwt`
|
|
225
228
|
* required as `typ`, `client_id` as the required client-id claim, 30
|
|
226
229
|
* seconds of clock skew, a 10-minute cache with a 30-second minimum
|
|
227
|
-
* between fetches, a 5-second request deadline,
|
|
228
|
-
* and the global `fetch`.
|
|
230
|
+
* between fetches, a 5-second request deadline, a 256 KiB response cap,
|
|
231
|
+
* {@linkcode BasicScope}, and the global `fetch`.
|
|
229
232
|
*
|
|
230
233
|
* @throws {TypeError} If `issuer` is not an absolute URL, `audience` is
|
|
231
234
|
* empty, or `algorithms` names an algorithm this package cannot verify
|
|
232
235
|
* (a silently ignored entry would read as a policy that isn't enforced).
|
|
233
236
|
* @throws {RangeError} If `clockSkewSeconds` is negative, non-finite, or
|
|
234
|
-
* overflows when converted to milliseconds
|
|
237
|
+
* overflows when converted to milliseconds, or `maxResponseBytes` is not
|
|
238
|
+
* a positive integer.
|
|
235
239
|
*/
|
|
236
240
|
constructor(options) {
|
|
237
241
|
this.#issuer = options.issuer;
|
|
@@ -255,6 +259,9 @@ export class JwksTokenReader {
|
|
|
255
259
|
this.#cacheMaxAgeMs = options.cacheMaxAgeMs ?? 600_000;
|
|
256
260
|
this.#minFetchIntervalMs = options.minFetchIntervalMs ?? 30_000;
|
|
257
261
|
this.#fetchTimeoutMs = options.fetchTimeoutMs ?? 5_000;
|
|
262
|
+
this.#maxResponseBytes =
|
|
263
|
+
options.maxResponseBytes ?? DEFAULT_MAX_KEY_SET_BYTES;
|
|
264
|
+
assertMaxBodyBytes(this.#maxResponseBytes, "maxResponseBytes");
|
|
258
265
|
this.#Scope =
|
|
259
266
|
options.Scope ?? BasicScope;
|
|
260
267
|
this.#getClient = options.getClient;
|
|
@@ -282,8 +289,10 @@ export class JwksTokenReader {
|
|
|
282
289
|
* invalid token, not collapsed into it. Once keys are cached, a failed
|
|
283
290
|
* refresh is absorbed and the cached keys keep serving.
|
|
284
291
|
* @throws {ServerError} If, with no keys cached, the JWKS endpoint returns
|
|
285
|
-
* a 4xx or a body that is not a JWKS,
|
|
286
|
-
*
|
|
292
|
+
* a 4xx or a body that is not a JWKS, either document exceeds
|
|
293
|
+
* {@linkcode JwksTokenReaderOptions.maxResponseBytes}, or discovery
|
|
294
|
+
* reports no `jwks_uri` — a misconfiguration, not a verdict on the
|
|
295
|
+
* caller's token.
|
|
287
296
|
*/
|
|
288
297
|
async getToken(accessToken) {
|
|
289
298
|
const parsed = parseJws(accessToken);
|
|
@@ -499,12 +508,7 @@ export class JwksTokenReader {
|
|
|
499
508
|
? new TemporarilyUnavailableError(`${label} request failed (HTTP ${response.status})`)
|
|
500
509
|
: new ServerError(`${label} request failed (HTTP ${response.status})`);
|
|
501
510
|
}
|
|
502
|
-
|
|
503
|
-
return await response.json();
|
|
504
|
-
}
|
|
505
|
-
catch (cause) {
|
|
506
|
-
throw new ServerError(`${label} response was not JSON`, { cause });
|
|
507
|
-
}
|
|
511
|
+
return await readResponseJson(response, this.#maxResponseBytes, label);
|
|
508
512
|
}
|
|
509
513
|
finally {
|
|
510
514
|
clearTimeout(deadline);
|
package/dist/utils/_body.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Internal bounded
|
|
3
|
-
* before they have authenticated the caller
|
|
2
|
+
* Internal bounded body reader for the request bodies the endpoints parse
|
|
3
|
+
* before they have authenticated the caller, and for the responses the
|
|
4
|
+
* package reads from the servers it calls.
|
|
4
5
|
*
|
|
5
6
|
* @module
|
|
6
7
|
*/
|
|
@@ -17,20 +18,30 @@ export declare const DEFAULT_MAX_BODY_BYTES: number;
|
|
|
17
18
|
* block, so any token a client could send as `Authorization: Bearer` fits.
|
|
18
19
|
*/
|
|
19
20
|
export declare const DEFAULT_MAX_TOKEN_BODY_BYTES: number;
|
|
21
|
+
/**
|
|
22
|
+
* The default cap on an introspection response: 64 KiB, the same cap the
|
|
23
|
+
* clients apply to every authorization-server response.
|
|
24
|
+
*/
|
|
25
|
+
export declare const DEFAULT_MAX_RESPONSE_BYTES: number;
|
|
26
|
+
/**
|
|
27
|
+
* The default cap on a discovery document or JWKS: 256 KiB, room for dozens
|
|
28
|
+
* of RSA keys that each carry an X.509 certificate chain.
|
|
29
|
+
*/
|
|
30
|
+
export declare const DEFAULT_MAX_KEY_SET_BYTES: number;
|
|
20
31
|
/**
|
|
21
32
|
* Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
|
|
22
33
|
* safe integer.
|
|
23
34
|
*/
|
|
24
35
|
export declare function assertMaxBodyBytes(maxBodyBytes: number, option: string): void;
|
|
25
36
|
/**
|
|
26
|
-
* Reads `request`'s body
|
|
27
|
-
* once it exceeds `maxBytes
|
|
28
|
-
* A `Content-Length` over the cap is refused without
|
|
29
|
-
* not trusted. Consumes the body unless
|
|
30
|
-
* clone and leaves the original readable
|
|
31
|
-
* the stream errors or was already read.
|
|
37
|
+
* Reads `request`'s body — or a response's — counting bytes as they arrive,
|
|
38
|
+
* and returns `null` once it exceeds `maxBytes`, cancelling the stream rather
|
|
39
|
+
* than draining it. A `Content-Length` over the cap is refused without
|
|
40
|
+
* reading; one under it is not trusted. Consumes the body unless
|
|
41
|
+
* `keepReadable` is set, which reads a clone and leaves the original readable
|
|
42
|
+
* (a refusal cancels both). Rejects if the stream errors or was already read.
|
|
32
43
|
*/
|
|
33
|
-
export declare function readBoundedBody(request: Request, maxBytes: number, options?: {
|
|
44
|
+
export declare function readBoundedBody(request: Request | Response, maxBytes: number, options?: {
|
|
34
45
|
keepReadable?: boolean;
|
|
35
46
|
}): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
36
47
|
/**
|
package/dist/utils/_body.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Internal bounded
|
|
3
|
-
* before they have authenticated the caller
|
|
2
|
+
* Internal bounded body reader for the request bodies the endpoints parse
|
|
3
|
+
* before they have authenticated the caller, and for the responses the
|
|
4
|
+
* package reads from the servers it calls.
|
|
4
5
|
*
|
|
5
6
|
* @module
|
|
6
7
|
*/
|
|
@@ -17,6 +18,16 @@ export const DEFAULT_MAX_BODY_BYTES = 64 * 1024;
|
|
|
17
18
|
* block, so any token a client could send as `Authorization: Bearer` fits.
|
|
18
19
|
*/
|
|
19
20
|
export const DEFAULT_MAX_TOKEN_BODY_BYTES = 16 * 1024;
|
|
21
|
+
/**
|
|
22
|
+
* The default cap on an introspection response: 64 KiB, the same cap the
|
|
23
|
+
* clients apply to every authorization-server response.
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 64 * 1024;
|
|
26
|
+
/**
|
|
27
|
+
* The default cap on a discovery document or JWKS: 256 KiB, room for dozens
|
|
28
|
+
* of RSA keys that each carry an X.509 certificate chain.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_MAX_KEY_SET_BYTES = 256 * 1024;
|
|
20
31
|
/**
|
|
21
32
|
* Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
|
|
22
33
|
* safe integer.
|
|
@@ -27,12 +38,12 @@ export function assertMaxBodyBytes(maxBodyBytes, option) {
|
|
|
27
38
|
}
|
|
28
39
|
}
|
|
29
40
|
/**
|
|
30
|
-
* Reads `request`'s body
|
|
31
|
-
* once it exceeds `maxBytes
|
|
32
|
-
* A `Content-Length` over the cap is refused without
|
|
33
|
-
* not trusted. Consumes the body unless
|
|
34
|
-
* clone and leaves the original readable
|
|
35
|
-
* the stream errors or was already read.
|
|
41
|
+
* Reads `request`'s body — or a response's — counting bytes as they arrive,
|
|
42
|
+
* and returns `null` once it exceeds `maxBytes`, cancelling the stream rather
|
|
43
|
+
* than draining it. A `Content-Length` over the cap is refused without
|
|
44
|
+
* reading; one under it is not trusted. Consumes the body unless
|
|
45
|
+
* `keepReadable` is set, which reads a clone and leaves the original readable
|
|
46
|
+
* (a refusal cancels both). Rejects if the stream errors or was already read.
|
|
36
47
|
*/
|
|
37
48
|
export async function readBoundedBody(request, maxBytes, options = {}) {
|
|
38
49
|
if (!request.body)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"authorization-server",
|