@udibo/oauth2 0.5.0 → 0.6.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.
@@ -48,6 +48,8 @@ export declare function sendGuarded(fetchImpl: typeof fetch, url: string, init:
48
48
  * @param res The response from {@link sendGuarded}.
49
49
  * @param what The endpoint's name, for error messages.
50
50
  * @returns The parsed JSON object.
51
+ * @throws {TemporarilyUnavailableError} on a `2xx` whose body stops arriving
52
+ * within the size cap — the deadline fires or the connection drops mid-body.
51
53
  * @throws {OAuth2Error} on a non-OK status, an oversized body, a body that is
52
54
  * not valid JSON, or JSON that is not an object.
53
55
  */
@@ -81,6 +81,8 @@ export async function sendGuarded(fetchImpl, url, init, what, options = {}) {
81
81
  * @param res The response from {@link sendGuarded}.
82
82
  * @param what The endpoint's name, for error messages.
83
83
  * @returns The parsed JSON object.
84
+ * @throws {TemporarilyUnavailableError} on a `2xx` whose body stops arriving
85
+ * within the size cap — the deadline fires or the connection drops mid-body.
84
86
  * @throws {OAuth2Error} on a non-OK status, an oversized body, a body that is
85
87
  * not valid JSON, or JSON that is not an object.
86
88
  */
@@ -92,6 +94,10 @@ export async function receiveJson(res, what) {
92
94
  throw new ServerError(`the ${what} response exceeded ${MAX_RESPONSE_BYTES} bytes — refusing ` +
93
95
  `to parse a response this large`);
94
96
  }
97
+ if (body.interrupted) {
98
+ throw new TemporarilyUnavailableError(`the ${what} response body did not arrive in full ` +
99
+ `(${describeError(body.interrupted.cause)})`, { cause: body.interrupted.cause });
100
+ }
95
101
  let parsed;
96
102
  try {
97
103
  parsed = JSON.parse(body.text);
@@ -184,8 +190,8 @@ async function readBounded(res) {
184
190
  size += value.byteLength;
185
191
  }
186
192
  }
187
- catch {
188
- return { text: decode(chunks, size), truncated: true };
193
+ catch (cause) {
194
+ return { text: decode(chunks, size), truncated, interrupted: { cause } };
189
195
  }
190
196
  finally {
191
197
  await reader.cancel().catch(() => { });
@@ -96,9 +96,10 @@ export interface CheckPermissionsResult {
96
96
  * deleted after the signature, so catch it on that path rather than reading a
97
97
  * throw as a bug in your own setup. A redirect, or a `2xx` whose body is not a
98
98
  * JSON object or exceeds the response-size cap, also throws
99
- * {@linkcode ServerError}, and a request whose response headers do not
100
- * arrive before the deadline throws {@linkcode TemporarilyUnavailableError}.
101
- * The body's shape is not validated beyond being a JSON object.
99
+ * {@linkcode ServerError}. A response that does not arrive in full before the
100
+ * deadline — whether its headers or its body stall — or whose connection
101
+ * drops mid-body throws {@linkcode TemporarilyUnavailableError}. The body's
102
+ * shape is not validated beyond being a JSON object.
102
103
  *
103
104
  * @example
104
105
  * ```ts
@@ -50,9 +50,10 @@ const CHECK_ENDPOINT = "check endpoint";
50
50
  * deleted after the signature, so catch it on that path rather than reading a
51
51
  * throw as a bug in your own setup. A redirect, or a `2xx` whose body is not a
52
52
  * JSON object or exceeds the response-size cap, also throws
53
- * {@linkcode ServerError}, and a request whose response headers do not
54
- * arrive before the deadline throws {@linkcode TemporarilyUnavailableError}.
55
- * The body's shape is not validated beyond being a JSON object.
53
+ * {@linkcode ServerError}. A response that does not arrive in full before the
54
+ * deadline — whether its headers or its body stall — or whose connection
55
+ * drops mid-body throws {@linkcode TemporarilyUnavailableError}. The body's
56
+ * shape is not validated beyond being a JSON object.
56
57
  *
57
58
  * @example
58
59
  * ```ts
@@ -89,7 +89,8 @@ export interface AuthRequestStorage {
89
89
  * before the token call but lets concurrent callers that both read it
90
90
  * before either deletes it redeem it twice. Implement it with the backing
91
91
  * store's own atomic primitive (`GETDEL`, `DELETE … RETURNING`) whenever
92
- * the store is shared between processes or requests.
92
+ * the store is shared between processes or requests, and check it with
93
+ * `runAuthRequestStorageContractTests` from `@udibo/oauth2/testing/contract`.
93
94
  */
94
95
  take?(state: string): Promise<AuthRequestRecord | null> | AuthRequestRecord | null;
95
96
  /** Removes one entry — a claimed or stale one. */
@@ -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,59 @@
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
+ * @module
26
+ */
27
+ import type { AuthRequestStorage } from "../../client/storage.js";
28
+ /** Options for {@link runAuthRequestStorageContractTests}. */
29
+ export interface AuthRequestStorageContractOptions {
30
+ /**
31
+ * Returns a fresh, empty store for each test. Isolation is required — a
32
+ * record surviving into the next test makes the single-use checks
33
+ * meaningless.
34
+ */
35
+ makeStore(): Promise<AuthRequestStorage> | AuthRequestStorage;
36
+ /**
37
+ * Set to `false` when the store deliberately omits the optional
38
+ * {@link AuthRequestStorage.take}. The suite then registers an ignored case
39
+ * naming what is not covered — two callbacks racing on one `state` can both
40
+ * redeem it — instead of passing in silence. Defaults to `true`, so a store
41
+ * that meant to implement it and does not fails loudly.
42
+ */
43
+ take?: boolean;
44
+ /**
45
+ * Overrides the name passed to the outer `describe` block. Defaults to
46
+ * `"AuthRequestStorage contract"`.
47
+ */
48
+ describeName?: string;
49
+ }
50
+ /**
51
+ * Runs the {@link AuthRequestStorage} contract suite. Call it from your own
52
+ * test file — it registers `describe` / `it` blocks the test runner picks up.
53
+ *
54
+ * The concurrency cases take one `state` from eight callers at once. A store
55
+ * whose `take` reads, awaits, then deletes hands the record to more than one
56
+ * of them and fails in practice — a race is probabilistic, so a lucky
57
+ * interleaving can still pass a single run.
58
+ */
59
+ export declare function runAuthRequestStorageContractTests(options: AuthRequestStorageContractOptions): void;
@@ -0,0 +1,133 @@
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
+ * @module
26
+ */
27
+ import { assert, assertEquals, assertStrictEquals } from "../../deps/jsr.io/@std/assert/1.0.19/mod.js";
28
+ import { beforeEach, describe, it } from "../../deps/jsr.io/@std/testing/1.0.20/bdd.js";
29
+ import { CONCURRENT_CALLERS, race } from "./_race.js";
30
+ function record(overrides = {}) {
31
+ return {
32
+ codeVerifier: "verifier-1",
33
+ returnTo: "/dashboard",
34
+ scope: "openid profile",
35
+ createdAt: Date.now(),
36
+ ...overrides,
37
+ };
38
+ }
39
+ /**
40
+ * Runs the {@link AuthRequestStorage} contract suite. Call it from your own
41
+ * test file — it registers `describe` / `it` blocks the test runner picks up.
42
+ *
43
+ * The concurrency cases take one `state` from eight callers at once. A store
44
+ * whose `take` reads, awaits, then deletes hands the record to more than one
45
+ * of them and fails in practice — a race is probabilistic, so a lucky
46
+ * interleaving can still pass a single run.
47
+ */
48
+ export function runAuthRequestStorageContractTests(options) {
49
+ const expectTake = options.take ?? true;
50
+ describe(options.describeName ?? "AuthRequestStorage contract", () => {
51
+ let store;
52
+ beforeEach(async () => {
53
+ store = await options.makeStore();
54
+ });
55
+ describe("set / get / delete / clear", () => {
56
+ it("returns null for an unknown state", async () => {
57
+ assertStrictEquals(await store.get("no-such-state"), null);
58
+ });
59
+ it("round-trips every field of a stored record", async () => {
60
+ const stored = record();
61
+ await store.set("state-1", stored);
62
+ assertEquals(await store.get("state-1"), stored);
63
+ });
64
+ it("keeps records under different states independent", async () => {
65
+ await store.set("state-1", record({ codeVerifier: "verifier-1" }));
66
+ await store.set("state-2", record({ codeVerifier: "verifier-2" }));
67
+ assertStrictEquals((await store.get("state-1"))?.codeVerifier, "verifier-1");
68
+ assertStrictEquals((await store.get("state-2"))?.codeVerifier, "verifier-2");
69
+ });
70
+ it("deletes one record and leaves the others", async () => {
71
+ await store.set("state-1", record());
72
+ await store.set("state-2", record());
73
+ await store.delete("state-1");
74
+ assertStrictEquals(await store.get("state-1"), null);
75
+ assert(await store.get("state-2") !== null, "delete must remove only the named state");
76
+ });
77
+ it("clears every record", async () => {
78
+ await store.set("state-1", record());
79
+ await store.set("state-2", record());
80
+ await store.clear();
81
+ assertStrictEquals(await store.get("state-1"), null);
82
+ assertStrictEquals(await store.get("state-2"), null);
83
+ });
84
+ });
85
+ if (expectTake) {
86
+ describe("take", () => {
87
+ it("is implemented", () => {
88
+ assert(typeof store.take === "function", "take is optional, but without it DirectClient falls back to " +
89
+ "get then delete, and two callbacks racing on one state can " +
90
+ "both redeem it. Implement it, or pass `take: false` to record " +
91
+ "the gap.");
92
+ });
93
+ it("returns the record and removes it", async () => {
94
+ const stored = record();
95
+ await store.set("state-1", stored);
96
+ assertEquals(await store.take("state-1"), stored);
97
+ assertStrictEquals(await store.get("state-1"), null, "a taken record must not be readable again");
98
+ assertStrictEquals(await store.take("state-1"), null);
99
+ });
100
+ it("returns null for an unknown state", async () => {
101
+ assertStrictEquals(await store.take("no-such-state"), null);
102
+ });
103
+ it(`hands one record to exactly one of ${CONCURRENT_CALLERS} concurrent takes`, async () => {
104
+ const stored = record();
105
+ await store.set("state-1", stored);
106
+ const results = await race(() => store.take("state-1"));
107
+ const winners = results.filter((result) => result !== null);
108
+ assertStrictEquals(winners.length, 1, "a state is single-use — callbacks racing on it must not each " +
109
+ "receive the PKCE verifier");
110
+ assertEquals(winners[0], stored);
111
+ assertStrictEquals(await store.get("state-1"), null);
112
+ });
113
+ it("hands each record to its own taker when two states are taken concurrently", async () => {
114
+ await store.set("state-1", record({ codeVerifier: "verifier-1" }));
115
+ await store.set("state-2", record({ codeVerifier: "verifier-2" }));
116
+ const [first, second] = await Promise.all([
117
+ store.take("state-1"),
118
+ store.take("state-2"),
119
+ ]);
120
+ assertStrictEquals(first?.codeVerifier, "verifier-1");
121
+ assertStrictEquals(second?.codeVerifier, "verifier-2");
122
+ });
123
+ });
124
+ }
125
+ else {
126
+ it({
127
+ name: "take is not implemented — two callbacks racing on one state can both redeem it (take: false)",
128
+ ignore: true,
129
+ fn: () => { },
130
+ });
131
+ }
132
+ });
133
+ }
@@ -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 () => {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Contract test suite for a Udibo tenant's authorization answers: what
2
+ * Contract test suite for a Udibo Identity tenant's authorization answers: what
3
3
  * introspection reports about a signed-in person, what `GET /api/memberships`
4
4
  * lists for them, and what `POST /api/check` and `POST /api/check/batch`
5
5
  * answer.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Contract test suite for a Udibo tenant's authorization answers: what
2
+ * Contract test suite for a Udibo Identity tenant's authorization answers: what
3
3
  * introspection reports about a signed-in person, what `GET /api/memberships`
4
4
  * lists for them, and what `POST /api/check` and `POST /api/check/batch`
5
5
  * answer.
@@ -32,7 +32,7 @@ import { encodeBasicAuth } from "../../utils/basic-auth.js";
32
32
  * safe as long as nothing else uses that namespace.
33
33
  */
34
34
  export function runTenantContractTests(options) {
35
- describe(options.describeName ?? "Udibo tenant contract", () => {
35
+ describe(options.describeName ?? "Udibo Identity tenant contract", () => {
36
36
  let tenant;
37
37
  let metadata;
38
38
  let send;
@@ -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" }));
@@ -26,10 +26,10 @@
26
26
  * user / client / scope types. Suitable for tests, examples, and
27
27
  * prototypes — never for production.
28
28
  *
29
- * - **A fake Udibo tenant.** {@link createFakeTenant} answers a tenant's
29
+ * - **A fake Udibo Identity tenant.** {@link createFakeTenant} answers a tenant's
30
30
  * protocol surface and its `/api/check` questions over `fetch`, with the
31
31
  * claims a real tenant issues — permissions, the organization picked at
32
- * sign-in, and resource grants — so an app built on a Udibo tenant can test
32
+ * sign-in, and resource grants — so an app built on a Udibo Identity tenant can test
33
33
  * its own routes without running the identity service.
34
34
  *
35
35
  * Nothing in this module is an OAuth2 primitive — only testing support.
@@ -26,10 +26,10 @@
26
26
  * user / client / scope types. Suitable for tests, examples, and
27
27
  * prototypes — never for production.
28
28
  *
29
- * - **A fake Udibo tenant.** {@link createFakeTenant} answers a tenant's
29
+ * - **A fake Udibo Identity tenant.** {@link createFakeTenant} answers a tenant's
30
30
  * protocol surface and its `/api/check` questions over `fetch`, with the
31
31
  * claims a real tenant issues — permissions, the organization picked at
32
- * sign-in, and resource grants — so an app built on a Udibo tenant can test
32
+ * sign-in, and resource grants — so an app built on a Udibo Identity tenant can test
33
33
  * its own routes without running the identity service.
34
34
  *
35
35
  * Nothing in this module is an OAuth2 primitive — only testing support.
@@ -102,7 +102,7 @@ export interface FakeTenantOptions {
102
102
  issuer: string;
103
103
  }
104
104
  /**
105
- * A stand-in for a Udibo tenant, for testing an app that signs in against one
105
+ * A stand-in for a Udibo Identity tenant, for testing an app that signs in against one
106
106
  * without running the identity service. It answers the tenant's protocol
107
107
  * surface — discovery, authorize, token, introspection, UserInfo, JWKS — the
108
108
  * caller's own `GET /api/memberships`, and the two authorization questions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
5
5
  "repository": {
6
6
  "type": "git",