@udibo/oauth2 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,7 @@ import { DeviceAuthorizationGrant } from "./grants/device-authorization.js";
29
29
  import { ResourceServer, } from "./resource-server.js";
30
30
  import { defaultSubjectOf, signJwt, verifyJwt, } from "./signing-keys.js";
31
31
  import { validateCodeChallenge } from "../utils/pkce.js";
32
+ import { assertMaxBodyBytes, DEFAULT_MAX_BODY_BYTES, readBoundedBody, } from "../utils/_body.js";
32
33
  const ENDPOINTS = {
33
34
  authorization: {
34
35
  option: "authorizationEndpoint",
@@ -186,6 +187,7 @@ export class AuthorizationServer extends ResourceServer {
186
187
  signingKeys;
187
188
  #endSession;
188
189
  #isPublicSuffix;
190
+ #maxBodyBytes;
189
191
  #subjectOf;
190
192
  #userClaims;
191
193
  #introspectionClaims;
@@ -197,6 +199,7 @@ export class AuthorizationServer extends ResourceServer {
197
199
  *
198
200
  * @throws {Error} If any grant's map key does not equal its `grantType`.
199
201
  * @throws {Error} If `requireOidc` is set without `signingKeys`.
202
+ * @throws {RangeError} If `maxBodyBytes` is not a positive integer.
200
203
  */
201
204
  constructor(options) {
202
205
  super({
@@ -227,6 +230,8 @@ export class AuthorizationServer extends ResourceServer {
227
230
  "plain OAuth2.");
228
231
  }
229
232
  this.#isPublicSuffix = options.isPublicSuffix;
233
+ this.#maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
234
+ assertMaxBodyBytes(this.#maxBodyBytes, "maxBodyBytes");
230
235
  this.#subjectOf = options.subjectOf ?? defaultSubjectOf;
231
236
  this.#userClaims = options.userClaims;
232
237
  this.#introspectionClaims = options.introspectionClaims;
@@ -343,6 +348,18 @@ export class AuthorizationServer extends ResourceServer {
343
348
  }
344
349
  return grant;
345
350
  }
351
+ async #readBody(request, options = {}) {
352
+ const bytes = await readBoundedBody(request, this.#maxBodyBytes, options)
353
+ .catch((cause) => {
354
+ throw new InvalidRequestError("request body could not be read", {
355
+ cause,
356
+ });
357
+ });
358
+ if (!bytes) {
359
+ throw new InvalidRequestError(413, `request body exceeds ${this.#maxBodyBytes} bytes`);
360
+ }
361
+ return bytes;
362
+ }
346
363
  async #beginClientAuthenticatedRequest(request, options = {}) {
347
364
  if (request.method !== "POST") {
348
365
  throw new InvalidRequestError("method must be POST");
@@ -351,10 +368,13 @@ export class AuthorizationServer extends ResourceServer {
351
368
  if (!contentType?.includes("application/x-www-form-urlencoded")) {
352
369
  throw new InvalidRequestError("content-type header must be application/x-www-form-urlencoded");
353
370
  }
354
- const context = await this.authorizationContext(request);
355
- const body = await request.clone().formData().catch(() => {
371
+ const bytes = await this.#readBody(request, { keepReadable: true });
372
+ const body = await new Response(bytes, {
373
+ headers: { "content-type": contentType },
374
+ }).formData().catch(() => {
356
375
  throw new InvalidRequestError("body must be application/x-www-form-urlencoded");
357
376
  });
377
+ const context = await this.authorizationContext(request);
358
378
  const grant = options.grantFromBody?.(body);
359
379
  const client = grant
360
380
  ? await grant.getAuthenticatedClient(request, body)
@@ -372,7 +392,9 @@ export class AuthorizationServer extends ResourceServer {
372
392
  * `application/x-www-form-urlencoded`, or no `grant_type` — is a 400
373
393
  * `invalid_request`. A `grant_type` with no registered grant is
374
394
  * `unsupported_grant_type`, checked before client authentication; a client
375
- * not registered for the grant is `unauthorized_client`.
395
+ * not registered for the grant is `unauthorized_client`. A body over
396
+ * {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
397
+ * `invalid_request`, refused before the request context is resolved.
376
398
  */
377
399
  async handleTokenRequest(request) {
378
400
  try {
@@ -523,7 +545,9 @@ export class AuthorizationServer extends ResourceServer {
523
545
  * when sent, names the client ahead of the hint's audience, and when both
524
546
  * are sent `client_id` must be one of the hint's audiences — a mismatch is
525
547
  * a 400 `invalid_request` (OIDC RP-Initiated Logout 1.0 §2). `state` rides
526
- * along only to a URI that passed authorization.
548
+ * along only to a URI that passed authorization. A POST body over
549
+ * {@linkcode AuthorizationServerOptions.maxBodyBytes} is a 413
550
+ * `invalid_request`, refused before `endSession` runs.
527
551
  *
528
552
  * Wire the app's session teardown through the `endSession` **constructor
529
553
  * option**; this method answers 404 without it, and the discovery document
@@ -572,7 +596,7 @@ export class AuthorizationServer extends ResourceServer {
572
596
  async #endSessionParameters(request) {
573
597
  const url = new URL(request.url);
574
598
  const params = request.method === "POST"
575
- ? new URLSearchParams(await request.text())
599
+ ? new URLSearchParams(new TextDecoder().decode(await this.#readBody(request)))
576
600
  : url.searchParams;
577
601
  const idTokenHint = params.get("id_token_hint");
578
602
  const hint = idTokenHint ? await this.#readIdTokenHint(idTokenHint) : null;
@@ -0,0 +1,2 @@
1
+ export declare const CONCURRENT_CALLERS = 8;
2
+ export declare function race<T>(call: (caller: number) => Promise<T> | T): Promise<Awaited<T>[]>;
@@ -0,0 +1,4 @@
1
+ export const CONCURRENT_CALLERS = 8;
2
+ export function race(call) {
3
+ return Promise.all(Array.from({ length: CONCURRENT_CALLERS }, (_, caller) => call(caller)));
4
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Contract test suite for {@link AuthRequestStorage} implementations — the
3
+ * short-lived map from an authorization request's `state` to the PKCE verifier
4
+ * it was built with.
5
+ *
6
+ * The load-bearing method is the optional `take`: `DirectClient` claims the
7
+ * record with it before calling the token endpoint, so of two callbacks racing
8
+ * on one `state` only one reaches it. Only an atomic read-and-remove survives
9
+ * concurrent callers taking one record. This suite races eight of them on one
10
+ * record.
11
+ *
12
+ * @example Verify a Redis-backed store
13
+ * ```ts
14
+ * import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
15
+ * import type { AuthRequestStorage } from "@udibo/oauth2/client";
16
+ *
17
+ * declare function freshAuthRequestStorage(): Promise<AuthRequestStorage>;
18
+ *
19
+ * runAuthRequestStorageContractTests({
20
+ * describeName: "RedisAuthRequestStorage satisfies AuthRequestStorage contract",
21
+ * makeStore: freshAuthRequestStorage,
22
+ * });
23
+ * ```
24
+ *
25
+ * @example Verify a store shared between users, whose `clear()` removes only expired records
26
+ * ```ts
27
+ * import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
28
+ * import type { AuthRequestStorage } from "@udibo/oauth2/client";
29
+ *
30
+ * declare function freshSharedAuthRequestStorage(): Promise<AuthRequestStorage>;
31
+ *
32
+ * runAuthRequestStorageContractTests({
33
+ * describeName: "SharedAuthRequestStorage satisfies AuthRequestStorage contract",
34
+ * makeStore: freshSharedAuthRequestStorage,
35
+ * clear: "scoped",
36
+ * });
37
+ * ```
38
+ *
39
+ * @module
40
+ */
41
+ import type { AuthRequestStorage } from "../../client/storage.js";
42
+ /** Options for {@link runAuthRequestStorageContractTests}. */
43
+ export interface AuthRequestStorageContractOptions {
44
+ /**
45
+ * Returns a fresh, empty store for each test. Isolation is required — a
46
+ * record surviving into the next test makes the single-use checks
47
+ * meaningless.
48
+ */
49
+ makeStore(): Promise<AuthRequestStorage> | AuthRequestStorage;
50
+ /**
51
+ * Set to `false` when the store deliberately omits the optional
52
+ * {@link AuthRequestStorage.take}. The suite then registers an ignored case
53
+ * naming what is not covered — two callbacks racing on one `state` can both
54
+ * redeem it — instead of passing in silence. Defaults to `true`, so a store
55
+ * that meant to implement it and does not fails loudly.
56
+ */
57
+ take?: boolean;
58
+ /**
59
+ * What the store's {@link AuthRequestStorage.clear} removes. Defaults to
60
+ * `"all"`: every record, as a store private to one browser or session
61
+ * does. Pass `"scoped"` for a store shared between users whose `clear()`
62
+ * deliberately removes less — only expired records, say — because a
63
+ * `DirectClient` clears it on every sign-out, and removing everything would
64
+ * cancel other users' sign-ins still in progress. The suite then drops the
65
+ * clear-everything case and instead requires that `clear()` leaves every
66
+ * record still inside its lifetime readable and redeemable, and removes a
67
+ * record created at the epoch (`createdAt: 0`), which is past any lifetime.
68
+ */
69
+ clear?: "all" | "scoped";
70
+ /**
71
+ * Overrides the name passed to the outer `describe` block. Defaults to
72
+ * `"AuthRequestStorage contract"`.
73
+ */
74
+ describeName?: string;
75
+ }
76
+ /**
77
+ * Runs the {@link AuthRequestStorage} contract suite. Call it from your own
78
+ * test file — it registers `describe` / `it` blocks the test runner picks up.
79
+ *
80
+ * The concurrency cases take one `state` from eight callers at once. A store
81
+ * whose `take` reads, awaits, then deletes hands the record to more than one
82
+ * of them and fails in practice — a race is probabilistic, so a lucky
83
+ * interleaving can still pass a single run.
84
+ */
85
+ export declare function runAuthRequestStorageContractTests(options: AuthRequestStorageContractOptions): void;
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Contract test suite for {@link AuthRequestStorage} implementations — the
3
+ * short-lived map from an authorization request's `state` to the PKCE verifier
4
+ * it was built with.
5
+ *
6
+ * The load-bearing method is the optional `take`: `DirectClient` claims the
7
+ * record with it before calling the token endpoint, so of two callbacks racing
8
+ * on one `state` only one reaches it. Only an atomic read-and-remove survives
9
+ * concurrent callers taking one record. This suite races eight of them on one
10
+ * record.
11
+ *
12
+ * @example Verify a Redis-backed store
13
+ * ```ts
14
+ * import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
15
+ * import type { AuthRequestStorage } from "@udibo/oauth2/client";
16
+ *
17
+ * declare function freshAuthRequestStorage(): Promise<AuthRequestStorage>;
18
+ *
19
+ * runAuthRequestStorageContractTests({
20
+ * describeName: "RedisAuthRequestStorage satisfies AuthRequestStorage contract",
21
+ * makeStore: freshAuthRequestStorage,
22
+ * });
23
+ * ```
24
+ *
25
+ * @example Verify a store shared between users, whose `clear()` removes only expired records
26
+ * ```ts
27
+ * import { runAuthRequestStorageContractTests } from "@udibo/oauth2/testing/contract";
28
+ * import type { AuthRequestStorage } from "@udibo/oauth2/client";
29
+ *
30
+ * declare function freshSharedAuthRequestStorage(): Promise<AuthRequestStorage>;
31
+ *
32
+ * runAuthRequestStorageContractTests({
33
+ * describeName: "SharedAuthRequestStorage satisfies AuthRequestStorage contract",
34
+ * makeStore: freshSharedAuthRequestStorage,
35
+ * clear: "scoped",
36
+ * });
37
+ * ```
38
+ *
39
+ * @module
40
+ */
41
+ import { assert, assertEquals, assertStrictEquals } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
42
+ import { beforeEach, describe, it } from "../../deps/jsr.io/@std/testing/1.0.20/bdd.js";
43
+ import { CONCURRENT_CALLERS, race } from "./_race.js";
44
+ function record(overrides = {}) {
45
+ return {
46
+ codeVerifier: "verifier-1",
47
+ returnTo: "/dashboard",
48
+ scope: "openid profile",
49
+ createdAt: Date.now(),
50
+ ...overrides,
51
+ };
52
+ }
53
+ /**
54
+ * Runs the {@link AuthRequestStorage} contract suite. Call it from your own
55
+ * test file — it registers `describe` / `it` blocks the test runner picks up.
56
+ *
57
+ * The concurrency cases take one `state` from eight callers at once. A store
58
+ * whose `take` reads, awaits, then deletes hands the record to more than one
59
+ * of them and fails in practice — a race is probabilistic, so a lucky
60
+ * interleaving can still pass a single run.
61
+ */
62
+ export function runAuthRequestStorageContractTests(options) {
63
+ const expectTake = options.take ?? true;
64
+ const clearScope = options.clear ?? "all";
65
+ describe(options.describeName ?? "AuthRequestStorage contract", () => {
66
+ let store;
67
+ beforeEach(async () => {
68
+ store = await options.makeStore();
69
+ });
70
+ describe("set / get / delete / clear", () => {
71
+ it("returns null for an unknown state", async () => {
72
+ assertStrictEquals(await store.get("no-such-state"), null);
73
+ });
74
+ it("round-trips every field of a stored record", async () => {
75
+ const stored = record();
76
+ await store.set("state-1", stored);
77
+ assertEquals(await store.get("state-1"), stored);
78
+ });
79
+ it("keeps records under different states independent", async () => {
80
+ await store.set("state-1", record({ codeVerifier: "verifier-1" }));
81
+ await store.set("state-2", record({ codeVerifier: "verifier-2" }));
82
+ assertStrictEquals((await store.get("state-1"))?.codeVerifier, "verifier-1");
83
+ assertStrictEquals((await store.get("state-2"))?.codeVerifier, "verifier-2");
84
+ });
85
+ it("deletes one record and leaves the others", async () => {
86
+ await store.set("state-1", record());
87
+ await store.set("state-2", record());
88
+ await store.delete("state-1");
89
+ assertStrictEquals(await store.get("state-1"), null);
90
+ assert(await store.get("state-2") !== null, "delete must remove only the named state");
91
+ });
92
+ if (clearScope === "all") {
93
+ it("clears every record", async () => {
94
+ await store.set("state-1", record());
95
+ await store.set("state-2", record());
96
+ await store.clear();
97
+ assertStrictEquals(await store.get("state-1"), null);
98
+ assertStrictEquals(await store.get("state-2"), null);
99
+ });
100
+ }
101
+ else {
102
+ it('leaves every in-progress record in place (clear: "scoped")', async () => {
103
+ const first = record({ codeVerifier: "verifier-1" });
104
+ const second = record({ codeVerifier: "verifier-2" });
105
+ await store.set("state-1", first);
106
+ await store.set("state-2", second);
107
+ await store.clear();
108
+ const message = "clear() on a shared store must not cancel another user's sign-in " +
109
+ "that is still in progress";
110
+ assertEquals(await store.get("state-2"), second, message);
111
+ assertEquals(expectTake
112
+ ? await store.take("state-1")
113
+ : await store.get("state-1"), first, message);
114
+ });
115
+ it('removes a record past any lifetime (clear: "scoped")', async () => {
116
+ await store.set("expired", record({ createdAt: 0 }));
117
+ await store.clear();
118
+ assertStrictEquals(await store.get("expired"), null, "a scoped clear() must still remove expired records");
119
+ });
120
+ }
121
+ });
122
+ if (expectTake) {
123
+ describe("take", () => {
124
+ it("is implemented", () => {
125
+ assert(typeof store.take === "function", "take is optional, but without it DirectClient falls back to " +
126
+ "get then delete, and two callbacks racing on one state can " +
127
+ "both redeem it. Implement it, or pass `take: false` to record " +
128
+ "the gap.");
129
+ });
130
+ it("returns the record and removes it", async () => {
131
+ const stored = record();
132
+ await store.set("state-1", stored);
133
+ assertEquals(await store.take("state-1"), stored);
134
+ assertStrictEquals(await store.get("state-1"), null, "a taken record must not be readable again");
135
+ assertStrictEquals(await store.take("state-1"), null);
136
+ });
137
+ it("returns null for an unknown state", async () => {
138
+ assertStrictEquals(await store.take("no-such-state"), null);
139
+ });
140
+ it(`hands one record to exactly one of ${CONCURRENT_CALLERS} concurrent takes`, async () => {
141
+ const stored = record();
142
+ await store.set("state-1", stored);
143
+ const results = await race(() => store.take("state-1"));
144
+ const winners = results.filter((result) => result !== null);
145
+ assertStrictEquals(winners.length, 1, "a state is single-use — callbacks racing on it must not each " +
146
+ "receive the PKCE verifier");
147
+ assertEquals(winners[0], stored);
148
+ assertStrictEquals(await store.get("state-1"), null);
149
+ });
150
+ it("hands each record to its own taker when two states are taken concurrently", async () => {
151
+ await store.set("state-1", record({ codeVerifier: "verifier-1" }));
152
+ await store.set("state-2", record({ codeVerifier: "verifier-2" }));
153
+ const [first, second] = await Promise.all([
154
+ store.take("state-1"),
155
+ store.take("state-2"),
156
+ ]);
157
+ assertStrictEquals(first?.codeVerifier, "verifier-1");
158
+ assertStrictEquals(second?.codeVerifier, "verifier-2");
159
+ });
160
+ });
161
+ }
162
+ else {
163
+ it({
164
+ name: "take is not implemented — two callbacks racing on one state can both redeem it (take: false)",
165
+ ignore: true,
166
+ fn: () => { },
167
+ });
168
+ }
169
+ });
170
+ }
@@ -42,9 +42,10 @@ export interface MfaStoreContractOptions {
42
42
  * Runs the {@link MfaStore} contract suite. Call it from your own test file —
43
43
  * it registers `describe` / `it` blocks the test runner picks up.
44
44
  *
45
- * The atomicity cases issue two or more overlapping calls and assert the
46
- * outcome a conditional write produces. An implementation that reads, then
47
- * awaits, then writes fails them, which is the point: the same store passes
48
- * every sequential check.
45
+ * The atomicity cases issue overlapping calls — eight on one key where only one
46
+ * may win — and assert the outcome a conditional write produces. An
47
+ * implementation that reads, then awaits, then writes fails them in practice,
48
+ * which is the point: the same store passes every sequential check. A race is
49
+ * probabilistic, so a lucky interleaving can still pass a single run.
49
50
  */
50
51
  export declare function runMfaStoreContractTests(options: MfaStoreContractOptions): void;
@@ -25,6 +25,7 @@
25
25
  */
26
26
  import { assertEquals, assertStrictEquals } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
27
27
  import { beforeEach, describe, it } from "../../deps/jsr.io/@std/testing/1.0.20/bdd.js";
28
+ import { CONCURRENT_CALLERS, race } from "./_race.js";
28
29
  const USER = "user-1";
29
30
  const OTHER_USER = "user-2";
30
31
  const SECRET_A = "AAAAAAAAAAAAAAAA";
@@ -36,10 +37,11 @@ function countTrue(results) {
36
37
  * Runs the {@link MfaStore} contract suite. Call it from your own test file —
37
38
  * it registers `describe` / `it` blocks the test runner picks up.
38
39
  *
39
- * The atomicity cases issue two or more overlapping calls and assert the
40
- * outcome a conditional write produces. An implementation that reads, then
41
- * awaits, then writes fails them, which is the point: the same store passes
42
- * every sequential check.
40
+ * The atomicity cases issue overlapping calls — eight on one key where only one
41
+ * may win — and assert the outcome a conditional write produces. An
42
+ * implementation that reads, then awaits, then writes fails them in practice,
43
+ * which is the point: the same store passes every sequential check. A race is
44
+ * probabilistic, so a lucky interleaving can still pass a single run.
43
45
  */
44
46
  export function runMfaStoreContractTests(options) {
45
47
  describe(options.describeName ?? "MfaStore contract", () => {
@@ -98,12 +100,9 @@ export function runMfaStoreContractTests(options) {
98
100
  assertStrictEquals(record?.secretBase32, undefined);
99
101
  assertEquals(record?.pendingSecretBase32, SECRET_B);
100
102
  });
101
- it("activates for exactly one of two concurrent confirmations", async () => {
103
+ it(`activates for exactly one of ${CONCURRENT_CALLERS} concurrent confirmations`, async () => {
102
104
  await store.setPendingTotp(USER, SECRET_A);
103
- const results = await Promise.all([
104
- store.activateTotp(USER, SECRET_A, 10),
105
- store.activateTotp(USER, SECRET_A, 20),
106
- ]);
105
+ const results = await race((caller) => store.activateTotp(USER, SECRET_A, 10 + caller));
107
106
  assertStrictEquals(countTrue(results), 1, "the pending secret must be claimable once — a second concurrent " +
108
107
  "confirmation finds it already spent");
109
108
  const record = await store.getTotp(USER);
@@ -154,11 +153,8 @@ export function runMfaStoreContractTests(options) {
154
153
  it("refuses for a user with no record", async () => {
155
154
  assertStrictEquals(await store.advanceLastStep(OTHER_USER, 1), false);
156
155
  });
157
- it("admits exactly one of two concurrent claims on the same step", async () => {
158
- const results = await Promise.all([
159
- store.advanceLastStep(USER, 101),
160
- store.advanceLastStep(USER, 101),
161
- ]);
156
+ it(`admits exactly one of ${CONCURRENT_CALLERS} concurrent claims on the same step`, async () => {
157
+ const results = await race(() => store.advanceLastStep(USER, 101));
162
158
  assertStrictEquals(countTrue(results), 1, "a time step is spendable once — the loser must read as a replay");
163
159
  assertEquals((await store.getTotp(USER))?.lastStep, 101);
164
160
  });
@@ -202,14 +198,11 @@ export function runMfaStoreContractTests(options) {
202
198
  assertStrictEquals(await store.consumeRecoveryHash(USER, "nope"), false);
203
199
  assertEquals(await store.getRecoveryHashes(USER), ["h1"]);
204
200
  });
205
- it("burns a code for exactly one of two concurrent redemptions", async () => {
201
+ it(`burns a code for exactly one of ${CONCURRENT_CALLERS} concurrent redemptions`, async () => {
206
202
  await store.setRecoveryHashes(USER, ["h1", "h2"]);
207
- const results = await Promise.all([
208
- store.consumeRecoveryHash(USER, "h1"),
209
- store.consumeRecoveryHash(USER, "h1"),
210
- ]);
211
- assertStrictEquals(countTrue(results), 1, "a recovery code is single-use — two requests racing one code must " +
212
- "not both be told they redeemed it");
203
+ const results = await race(() => store.consumeRecoveryHash(USER, "h1"));
204
+ assertStrictEquals(countTrue(results), 1, "a recovery code is single-use — requests racing one code must " +
205
+ "not each be told they redeemed it");
213
206
  assertEquals(await store.getRecoveryHashes(USER), ["h2"]);
214
207
  });
215
208
  it("burns both codes when two different ones are redeemed concurrently", async () => {
@@ -11,11 +11,12 @@
11
11
  * library are tested by the exact same checks.
12
12
  *
13
13
  * The identity layer's storage seams — `MfaStore`, `TokenFlowStore`,
14
- * `OtpStore`, `RateLimitStore`, `LockoutStore` — have runners of their
15
- * own. Those interfaces document methods that **must be atomic**
16
- * (single-use recovery codes, single-use links, the TOTP replay guard,
17
- * the guess and failure budgets), and each suite checks that claim with
18
- * overlapping calls: a read-modify-write implementation passes every
14
+ * `OtpStore`, `RateLimitStore`, `LockoutStore` — and the client's
15
+ * `AuthRequestStorage` have runners of their own. Those interfaces document
16
+ * methods that **must be atomic** (single-use recovery codes, single-use
17
+ * links, the TOTP replay guard, the guess and failure budgets, the
18
+ * single-use claim of a login's `state`), and each suite checks that claim
19
+ * with overlapping calls: a read-modify-write implementation passes every
19
20
  * sequential case and fails the concurrent one, which is the whole
20
21
  * reason the suites exist.
21
22
  *
@@ -37,4 +38,5 @@ export { runTokenFlowStoreContractTests, type TokenFlowStoreContractOptions, } f
37
38
  export { type OtpStoreContractOptions, runOtpStoreContractTests, } from "./otp-store.js";
38
39
  export { type RateLimitStoreContractOptions, runRateLimitStoreContractTests, } from "./rate-limit-store.js";
39
40
  export { type LockoutStoreContractOptions, runLockoutStoreContractTests, } from "./lockout-store.js";
41
+ export { type AuthRequestStorageContractOptions, runAuthRequestStorageContractTests, } from "./auth-request-storage.js";
40
42
  export { runTenantContractTests, type TenantContractFixture, type TenantContractOptions, type TenantContractTokens, } from "./tenant.js";
@@ -11,11 +11,12 @@
11
11
  * library are tested by the exact same checks.
12
12
  *
13
13
  * The identity layer's storage seams — `MfaStore`, `TokenFlowStore`,
14
- * `OtpStore`, `RateLimitStore`, `LockoutStore` — have runners of their
15
- * own. Those interfaces document methods that **must be atomic**
16
- * (single-use recovery codes, single-use links, the TOTP replay guard,
17
- * the guess and failure budgets), and each suite checks that claim with
18
- * overlapping calls: a read-modify-write implementation passes every
14
+ * `OtpStore`, `RateLimitStore`, `LockoutStore` — and the client's
15
+ * `AuthRequestStorage` have runners of their own. Those interfaces document
16
+ * methods that **must be atomic** (single-use recovery codes, single-use
17
+ * links, the TOTP replay guard, the guess and failure budgets, the
18
+ * single-use claim of a login's `state`), and each suite checks that claim
19
+ * with overlapping calls: a read-modify-write implementation passes every
19
20
  * sequential case and fails the concurrent one, which is the whole
20
21
  * reason the suites exist.
21
22
  *
@@ -37,4 +38,5 @@ export { runTokenFlowStoreContractTests, } from "./token-flow-store.js";
37
38
  export { runOtpStoreContractTests, } from "./otp-store.js";
38
39
  export { runRateLimitStoreContractTests, } from "./rate-limit-store.js";
39
40
  export { runLockoutStoreContractTests, } from "./lockout-store.js";
41
+ export { runAuthRequestStorageContractTests, } from "./auth-request-storage.js";
40
42
  export { runTenantContractTests, } from "./tenant.js";
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import { assert, assertEquals, assertStrictEquals } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
26
26
  import { beforeEach, describe, it } from "../../deps/jsr.io/@std/testing/1.0.20/bdd.js";
27
+ import { CONCURRENT_CALLERS, race } from "./_race.js";
27
28
  const EMAIL = "user@example.com";
28
29
  const OTHER_EMAIL = "other@example.com";
29
30
  const PURPOSE = "signin";
@@ -94,14 +95,12 @@ export function runOtpStoreContractTests(options) {
94
95
  });
95
96
  });
96
97
  describe("consume", () => {
97
- it("claims an active code only once under concurrency", async () => {
98
+ it(`claims an active code for exactly one of ${CONCURRENT_CALLERS} concurrent consumers`, async () => {
98
99
  const created = record();
99
100
  await store.create(created);
100
- const results = await Promise.all([
101
- store.consume(created.id),
102
- store.consume(created.id),
103
- ]);
104
- assertEquals(results.sort(), [false, true]);
101
+ const results = await race(() => store.consume(created.id));
102
+ assertStrictEquals(results.filter((claimed) => claimed).length, 1, "a code is single-use — consumers racing one code must not each " +
103
+ "be told they claimed it");
105
104
  assertStrictEquals(await store.consume("missing-code"), false);
106
105
  });
107
106
  it("takes the record out of the active set", async () => {
@@ -3,8 +3,8 @@
3
3
  * behind verification links, password-reset links, and magic links.
4
4
  *
5
5
  * The load-bearing method is `markConsumed`: it is what makes a link
6
- * single-use, and only a conditional write survives two requests redeeming one
7
- * link at the same time. This suite issues those two requests.
6
+ * single-use, and only a conditional write survives concurrent requests
7
+ * redeeming one link. This suite races eight of them on one link.
8
8
  *
9
9
  * @example Verify a database-backed store
10
10
  * ```ts
@@ -48,7 +48,9 @@ export interface TokenFlowStoreContractOptions {
48
48
  * Runs the {@link TokenFlowStore} contract suite. Call it from your own test
49
49
  * file — it registers `describe` / `it` blocks the test runner picks up.
50
50
  *
51
- * The concurrency cases redeem one token from two callers at once; a store that
52
- * reads, awaits, then writes hands the token to both and fails them.
51
+ * The concurrency cases redeem one token from eight callers at once. A store
52
+ * that reads, awaits, then writes hands the token to more than one of them and
53
+ * fails in practice — a race is probabilistic, so a lucky interleaving can
54
+ * still pass a single run.
53
55
  */
54
56
  export declare function runTokenFlowStoreContractTests(options: TokenFlowStoreContractOptions): void;
@@ -3,8 +3,8 @@
3
3
  * behind verification links, password-reset links, and magic links.
4
4
  *
5
5
  * The load-bearing method is `markConsumed`: it is what makes a link
6
- * single-use, and only a conditional write survives two requests redeeming one
7
- * link at the same time. This suite issues those two requests.
6
+ * single-use, and only a conditional write survives concurrent requests
7
+ * redeeming one link. This suite races eight of them on one link.
8
8
  *
9
9
  * @example Verify a database-backed store
10
10
  * ```ts
@@ -23,6 +23,7 @@
23
23
  */
24
24
  import { assert, assertEquals, assertStrictEquals } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
25
25
  import { beforeEach, describe, it } from "../../deps/jsr.io/@std/testing/1.0.20/bdd.js";
26
+ import { CONCURRENT_CALLERS, race } from "./_race.js";
26
27
  const PURPOSE = "password-reset";
27
28
  const OTHER_PURPOSE = "email-verification";
28
29
  const SUBJECT = "user-1";
@@ -44,8 +45,10 @@ function record(overrides) {
44
45
  * Runs the {@link TokenFlowStore} contract suite. Call it from your own test
45
46
  * file — it registers `describe` / `it` blocks the test runner picks up.
46
47
  *
47
- * The concurrency cases redeem one token from two callers at once; a store that
48
- * reads, awaits, then writes hands the token to both and fails them.
48
+ * The concurrency cases redeem one token from eight callers at once. A store
49
+ * that reads, awaits, then writes hands the token to more than one of them and
50
+ * fails in practice — a race is probabilistic, so a lucky interleaving can
51
+ * still pass a single run.
49
52
  */
50
53
  export function runTokenFlowStoreContractTests(options) {
51
54
  const expectDeleteBySubject = options.deleteBySubject ?? true;
@@ -103,15 +106,12 @@ export function runTokenFlowStoreContractTests(options) {
103
106
  it("refuses an unknown token hash", async () => {
104
107
  assertStrictEquals(await store.markConsumed("no-such-hash", Date.now()), false);
105
108
  });
106
- it("claims a token for exactly one of two concurrent redemptions", async () => {
109
+ it(`claims a token for exactly one of ${CONCURRENT_CALLERS} concurrent redemptions`, async () => {
107
110
  await store.save(record({ tokenHash: "hash-1" }));
108
111
  const now = Date.now();
109
- const results = await Promise.all([
110
- store.markConsumed("hash-1", now),
111
- store.markConsumed("hash-1", now + 1),
112
- ]);
113
- assertStrictEquals(countTrue(results), 1, "a link is single-use — two requests racing one link must not both " +
114
- "be told they claimed it");
112
+ const results = await race((caller) => store.markConsumed("hash-1", now + caller));
113
+ assertStrictEquals(countTrue(results), 1, "a link is single-use — requests racing one link must not each be " +
114
+ "told they claimed it");
115
115
  });
116
116
  it("claims both tokens when two different ones are redeemed concurrently", async () => {
117
117
  await store.save(record({ tokenHash: "hash-1" }));
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Internal bounded request-body reader for the endpoints that parse a body
3
+ * before they have authenticated the caller.
4
+ *
5
+ * @module
6
+ */
7
+ /**
8
+ * The default cap on those bodies: 64 KiB. The largest legitimate field is a
9
+ * JWT (`id_token_hint`, `logout_token`, an introspected access token), and a
10
+ * token that also travels in an `Authorization` header already has to fit
11
+ * the 8–16 KiB header limits common to servers and proxies.
12
+ */
13
+ export declare const DEFAULT_MAX_BODY_BYTES: number;
14
+ /**
15
+ * Throws a `RangeError` naming `option` unless `maxBodyBytes` is a positive
16
+ * safe integer.
17
+ */
18
+ export declare function assertMaxBodyBytes(maxBodyBytes: number, option: string): void;
19
+ /**
20
+ * Reads `request`'s body, counting bytes as they arrive, and returns `null`
21
+ * once it exceeds `maxBytes` — cancelling the stream rather than draining it.
22
+ * A `Content-Length` over the cap is refused without reading; one under it is
23
+ * not trusted. Consumes the body unless `keepReadable` is set, which reads a
24
+ * clone and leaves the original readable (a refusal cancels both). Rejects if
25
+ * the stream errors or was already read.
26
+ */
27
+ export declare function readBoundedBody(request: Request, maxBytes: number, options?: {
28
+ keepReadable?: boolean;
29
+ }): Promise<Uint8Array<ArrayBuffer> | null>;