@udibo/oauth2 0.5.1 → 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.
- package/esm/client/_http.d.ts +2 -0
- package/esm/client/_http.js +8 -2
- package/esm/client/check.d.ts +4 -3
- package/esm/client/check.js +4 -3
- package/esm/client/storage.d.ts +2 -1
- package/esm/testing/contract/_race.d.ts +2 -0
- package/esm/testing/contract/_race.js +4 -0
- package/esm/testing/contract/auth-request-storage.d.ts +59 -0
- package/esm/testing/contract/auth-request-storage.js +133 -0
- package/esm/testing/contract/mfa-store.d.ts +5 -4
- package/esm/testing/contract/mfa-store.js +14 -21
- package/esm/testing/contract/mod.d.ts +7 -5
- package/esm/testing/contract/mod.js +7 -5
- package/esm/testing/contract/otp-store.js +5 -6
- package/esm/testing/contract/token-flow-store.d.ts +6 -4
- package/esm/testing/contract/token-flow-store.js +11 -11
- package/package.json +1 -1
package/esm/client/_http.d.ts
CHANGED
|
@@ -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
|
*/
|
package/esm/client/_http.js
CHANGED
|
@@ -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:
|
|
193
|
+
catch (cause) {
|
|
194
|
+
return { text: decode(chunks, size), truncated, interrupted: { cause } };
|
|
189
195
|
}
|
|
190
196
|
finally {
|
|
191
197
|
await reader.cancel().catch(() => { });
|
package/esm/client/check.d.ts
CHANGED
|
@@ -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}
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
package/esm/client/check.js
CHANGED
|
@@ -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}
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
package/esm/client/storage.d.ts
CHANGED
|
@@ -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,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
|
|
46
|
-
* outcome a conditional write produces. An
|
|
47
|
-
* awaits, then writes fails them
|
|
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
|
|
40
|
-
* outcome a conditional write produces. An
|
|
41
|
-
* awaits, then writes fails them
|
|
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(
|
|
103
|
+
it(`activates for exactly one of ${CONCURRENT_CALLERS} concurrent confirmations`, async () => {
|
|
102
104
|
await store.setPendingTotp(USER, SECRET_A);
|
|
103
|
-
const results = await
|
|
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(
|
|
158
|
-
const results = await
|
|
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(
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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` —
|
|
15
|
-
* own. Those interfaces document
|
|
16
|
-
* (single-use recovery codes, single-use
|
|
17
|
-
* the guess and failure budgets
|
|
18
|
-
*
|
|
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` —
|
|
15
|
-
* own. Those interfaces document
|
|
16
|
-
* (single-use recovery codes, single-use
|
|
17
|
-
* the guess and failure budgets
|
|
18
|
-
*
|
|
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(
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
7
|
-
*
|
|
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
|
|
52
|
-
* reads, awaits, then writes hands the token to
|
|
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
|
|
7
|
-
*
|
|
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
|
|
48
|
-
* reads, awaits, then writes hands the token to
|
|
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(
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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" }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "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",
|