@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.
- package/esm/adapters/hono/bff/bff.d.ts +23 -2
- package/esm/adapters/hono/bff/bff.js +28 -3
- package/esm/client/_http.d.ts +7 -2
- package/esm/client/_http.js +14 -5
- package/esm/client/check.d.ts +4 -3
- package/esm/client/check.js +15 -35
- package/esm/client/direct-client.d.ts +41 -2
- package/esm/client/direct-client.js +41 -2
- package/esm/client/storage.d.ts +10 -2
- package/esm/server/authorization-server.d.ts +27 -2
- package/esm/server/authorization-server.js +29 -5
- 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 +85 -0
- package/esm/testing/contract/auth-request-storage.js +170 -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/esm/utils/_body.d.ts +29 -0
- package/esm/utils/_body.js +66 -0
- package/package.json +1 -1
|
@@ -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
|
|
355
|
-
const body = await
|
|
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
|
|
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,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
|
|
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" }));
|
|
@@ -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>;
|