@udibo/oauth2 0.38.0 → 0.39.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.
@@ -69,7 +69,9 @@ export interface MfaStore {
69
69
  getRecoveryHashes(userId: string): Promise<string[]>;
70
70
  /**
71
71
  * Replace the user's recovery-code hashes with exactly this set — used for
72
- * initial issue, regeneration, and disable (`[]`).
72
+ * initial issue, regeneration, and disable (`[]`). `MfaService` issues codes
73
+ * only once the TOTP credential is active, so a store may keep recovery
74
+ * hashes on the credential's record and drop a write for a user without one.
73
75
  */
74
76
  setRecoveryHashes(userId: string, hashes: string[]): Promise<void>;
75
77
  /**
@@ -31,6 +31,17 @@ export interface LockoutStoreContractOptions {
31
31
  * asserts.
32
32
  */
33
33
  makeStore(): Promise<LockoutStore> | LockoutStore;
34
+ /**
35
+ * The account the cases act on, in your application's id format. A store
36
+ * that keeps the count on the user's own row needs it to name a user that
37
+ * exists. Defaults to `"user-1"`.
38
+ */
39
+ userId?: string;
40
+ /**
41
+ * A second account, distinct from {@link LockoutStoreContractOptions.userId},
42
+ * whose count must stay apart from the first. Defaults to `"user-2"`.
43
+ */
44
+ otherUserId?: string;
34
45
  /**
35
46
  * Overrides the name passed to the outer `describe` block. Defaults to
36
47
  * `"LockoutStore contract"`.
@@ -23,8 +23,6 @@
23
23
  * @module
24
24
  */
25
25
  import { beforeEach, describe, expect, it } from "vitest";
26
- const USER = "user-1";
27
- const OTHER_USER = "user-2";
28
26
  /**
29
27
  * Runs the {@link LockoutStore} contract suite. Call it from your own test
30
28
  * file — it registers `describe` / `it` blocks the test runner picks up.
@@ -33,6 +31,8 @@ const OTHER_USER = "user-2";
33
31
  * clock-free and never need a timer.
34
32
  */
35
33
  export function runLockoutStoreContractTests(options) {
34
+ const USER = options.userId ?? "user-1";
35
+ const OTHER_USER = options.otherUserId ?? "user-2";
36
36
  describe(options.describeName ?? "LockoutStore contract", () => {
37
37
  let store;
38
38
  let now;
@@ -32,6 +32,17 @@ export interface MfaStoreContractOptions {
32
32
  * meaningless.
33
33
  */
34
34
  makeStore(): Promise<MfaStore> | MfaStore;
35
+ /**
36
+ * The user the cases act on, in your application's id format. A store whose
37
+ * rows reference a users table needs it to name a user that exists. Defaults
38
+ * to `"user-1"`.
39
+ */
40
+ userId?: string;
41
+ /**
42
+ * A second user, distinct from {@link MfaStoreContractOptions.userId}, whose
43
+ * records must stay apart from the first. Defaults to `"user-2"`.
44
+ */
45
+ otherUserId?: string;
35
46
  /**
36
47
  * Overrides the name passed to the outer `describe` block. Defaults to
37
48
  * `"MfaStore contract"`.
@@ -25,8 +25,6 @@
25
25
  */
26
26
  import { beforeEach, describe, expect, it } from "vitest";
27
27
  import { CONCURRENT_CALLERS, race } from "./_race.js";
28
- const USER = "user-1";
29
- const OTHER_USER = "user-2";
30
28
  const SECRET_A = "AAAAAAAAAAAAAAAA";
31
29
  const SECRET_B = "BBBBBBBBBBBBBBBB";
32
30
  function countTrue(results) {
@@ -43,6 +41,8 @@ function countTrue(results) {
43
41
  * probabilistic, so a lucky interleaving can still pass a single run.
44
42
  */
45
43
  export function runMfaStoreContractTests(options) {
44
+ const USER = options.userId ?? "user-1";
45
+ const OTHER_USER = options.otherUserId ?? "user-2";
46
46
  describe(options.describeName ?? "MfaStore contract", () => {
47
47
  let store;
48
48
  beforeEach(async () => {
@@ -165,7 +165,19 @@ export function runMfaStoreContractTests(options) {
165
165
  expect((await store.getTotp(USER))?.lastStep, "the highest accepted step must survive a concurrent lower one").toStrictEqual(105);
166
166
  });
167
167
  });
168
+ describe("recovery hashes for a user without a credential", () => {
169
+ it("reads none and redeems none", async () => {
170
+ expect(await store.getRecoveryHashes(OTHER_USER)).toStrictEqual([]);
171
+ expect(await store.consumeRecoveryHash(OTHER_USER, "h1")).toBe(false);
172
+ });
173
+ });
168
174
  describe("recovery hashes", () => {
175
+ beforeEach(async () => {
176
+ for (const user of [USER, OTHER_USER]) {
177
+ await store.setPendingTotp(user, SECRET_A);
178
+ await store.activateTotp(user, SECRET_A, 1);
179
+ }
180
+ });
169
181
  it("returns an empty list for a user with none", async () => {
170
182
  expect(await store.getRecoveryHashes(USER)).toStrictEqual([]);
171
183
  });
@@ -41,6 +41,9 @@ export interface OtpStoreContractOptions {
41
41
  * Runs the {@link OtpStore} contract suite. Call it from your own test file —
42
42
  * it registers `describe` / `it` blocks the test runner picks up.
43
43
  *
44
+ * Record ids, known and unknown alike, are UUIDs — the ids `EmailOtpService`
45
+ * issues — so a store with a `uuid` id column accepts every one.
46
+ *
44
47
  * The concurrency case fires overlapping `recordAttempt` calls and requires
45
48
  * each to see a distinct count; a store that reads, awaits, then writes hands
46
49
  * the same count to every guess and fails it.
@@ -28,11 +28,10 @@ const EMAIL = "user@example.com";
28
28
  const OTHER_EMAIL = "other@example.com";
29
29
  const PURPOSE = "signin";
30
30
  const OTHER_PURPOSE = "step-up";
31
- let sequence = 0;
32
31
  function record(overrides = {}) {
33
32
  const now = Date.now();
34
33
  return {
35
- id: `otp-${++sequence}`,
34
+ id: crypto.randomUUID(),
36
35
  email: EMAIL,
37
36
  purpose: PURPOSE,
38
37
  codeHash: "hash",
@@ -47,6 +46,9 @@ function record(overrides = {}) {
47
46
  * Runs the {@link OtpStore} contract suite. Call it from your own test file —
48
47
  * it registers `describe` / `it` blocks the test runner picks up.
49
48
  *
49
+ * Record ids, known and unknown alike, are UUIDs — the ids `EmailOtpService`
50
+ * issues — so a store with a `uuid` id column accepts every one.
51
+ *
50
52
  * The concurrency case fires overlapping `recordAttempt` calls and requires
51
53
  * each to see a distinct count; a store that reads, awaits, then writes hands
52
54
  * the same count to every guess and fails it.
@@ -100,7 +102,7 @@ export function runOtpStoreContractTests(options) {
100
102
  const results = await race(() => store.consume(created.id));
101
103
  expect(results.filter((claimed) => claimed).length, "a code is single-use — consumers racing one code must not each " +
102
104
  "be told they claimed it").toBe(1);
103
- expect(await store.consume("missing-code")).toBe(false);
105
+ expect(await store.consume(crypto.randomUUID())).toBe(false);
104
106
  });
105
107
  it("takes the record out of the active set", async () => {
106
108
  const created = record();
@@ -120,7 +122,7 @@ export function runOtpStoreContractTests(options) {
120
122
  it("is a no-op for an unknown id", async () => {
121
123
  const created = record();
122
124
  await store.create(created);
123
- await store.consume("no-such-id");
125
+ await store.consume(crypto.randomUUID());
124
126
  expect((await store.findActive(EMAIL, PURPOSE))?.id).toBe(created.id);
125
127
  });
126
128
  });
@@ -138,7 +140,7 @@ export function runOtpStoreContractTests(options) {
138
140
  it("is a no-op for an unknown id", async () => {
139
141
  const created = record();
140
142
  await store.create(created);
141
- await store.invalidateById("no-such-id");
143
+ await store.invalidateById(crypto.randomUUID());
142
144
  expect((await store.findActive(EMAIL, PURPOSE))?.id).toBe(created.id);
143
145
  });
144
146
  });
@@ -171,7 +173,7 @@ export function runOtpStoreContractTests(options) {
171
173
  expect(await store.recordAttempt(created.id)).toBe(3);
172
174
  });
173
175
  it("returns 0 for an unknown id", async () => {
174
- expect(await store.recordAttempt("no-such-id")).toBe(0);
176
+ expect(await store.recordAttempt(crypto.randomUUID())).toBe(0);
175
177
  });
176
178
  it("counts each record separately", async () => {
177
179
  const mine = record();
@@ -38,6 +38,18 @@ export interface TokenFlowStoreContractOptions {
38
38
  * that meant to implement it and does not fails loudly.
39
39
  */
40
40
  deleteBySubject?: boolean;
41
+ /**
42
+ * The subject the cases issue tokens for, in your application's id format. A
43
+ * store whose rows reference a users table needs it to name a user that
44
+ * exists. Defaults to `"user-1"`.
45
+ */
46
+ subject?: string;
47
+ /**
48
+ * A second subject, distinct from
49
+ * {@link TokenFlowStoreContractOptions.subject}, whose tokens must survive
50
+ * operations on the first. Defaults to `"user-2"`.
51
+ */
52
+ otherSubject?: string;
41
53
  /**
42
54
  * Overrides the name passed to the outer `describe` block. Defaults to
43
55
  * `"TokenFlowStore contract"`.
@@ -48,6 +60,9 @@ export interface TokenFlowStoreContractOptions {
48
60
  * Runs the {@link TokenFlowStore} contract suite. Call it from your own test
49
61
  * file — it registers `describe` / `it` blocks the test runner picks up.
50
62
  *
63
+ * Token hashes are unique to each test, so a store whose hash column is unique
64
+ * across tenants or test files never collides with an earlier run's rows.
65
+ *
51
66
  * The concurrency cases redeem one token from eight callers at once. A store
52
67
  * that reads, awaits, then writes hands the token to more than one of them and
53
68
  * fails in practice — a race is probabilistic, so a lucky interleaving can
@@ -25,25 +25,16 @@ import { assert, beforeEach, describe, expect, it } from "vitest";
25
25
  import { CONCURRENT_CALLERS, race } from "./_race.js";
26
26
  const PURPOSE = "password-reset";
27
27
  const OTHER_PURPOSE = "email-verification";
28
- const SUBJECT = "user-1";
29
- const OTHER_SUBJECT = "user-2";
30
28
  function countTrue(results) {
31
29
  return results.filter((result) => result).length;
32
30
  }
33
- function record(overrides) {
34
- const now = Date.now();
35
- return {
36
- purpose: PURPOSE,
37
- subject: SUBJECT,
38
- expiresAt: now + 60_000,
39
- createdAt: now,
40
- ...overrides,
41
- };
42
- }
43
31
  /**
44
32
  * Runs the {@link TokenFlowStore} contract suite. Call it from your own test
45
33
  * file — it registers `describe` / `it` blocks the test runner picks up.
46
34
  *
35
+ * Token hashes are unique to each test, so a store whose hash column is unique
36
+ * across tenants or test files never collides with an earlier run's rows.
37
+ *
47
38
  * The concurrency cases redeem one token from eight callers at once. A store
48
39
  * that reads, awaits, then writes hands the token to more than one of them and
49
40
  * fails in practice — a race is probabilistic, so a lucky interleaving can
@@ -51,22 +42,39 @@ function record(overrides) {
51
42
  */
52
43
  export function runTokenFlowStoreContractTests(options) {
53
44
  const expectDeleteBySubject = options.deleteBySubject ?? true;
45
+ const SUBJECT = options.subject ?? "user-1";
46
+ const OTHER_SUBJECT = options.otherSubject ?? "user-2";
54
47
  describe(options.describeName ?? "TokenFlowStore contract", () => {
55
48
  let store;
49
+ let testId;
50
+ function hash(name) {
51
+ return `hash-${name}-${testId}`;
52
+ }
53
+ function record(overrides) {
54
+ const now = Date.now();
55
+ return {
56
+ purpose: PURPOSE,
57
+ subject: SUBJECT,
58
+ expiresAt: now + 60_000,
59
+ createdAt: now,
60
+ ...overrides,
61
+ };
62
+ }
56
63
  beforeEach(async () => {
64
+ testId = crypto.randomUUID();
57
65
  store = await options.makeStore();
58
66
  });
59
67
  describe("save / get", () => {
60
68
  it("returns null for an unknown token hash", async () => {
61
- expect(await store.get("no-such-hash")).toBe(null);
69
+ expect(await store.get(hash("unknown"))).toBe(null);
62
70
  });
63
71
  it("round-trips every field of a saved record", async () => {
64
72
  const saved = record({
65
- tokenHash: "hash-1",
73
+ tokenHash: hash("1"),
66
74
  data: { email: "user@example.com" },
67
75
  });
68
76
  await store.save(saved);
69
- const fetched = await store.get("hash-1");
77
+ const fetched = await store.get(hash("1"));
70
78
  assert(fetched !== null, "a saved record must be readable back");
71
79
  expect(fetched.purpose).toStrictEqual(saved.purpose);
72
80
  expect(fetched.subject).toStrictEqual(saved.subject);
@@ -76,53 +84,53 @@ export function runTokenFlowStoreContractTests(options) {
76
84
  expect(fetched.consumedAt).toBe(undefined);
77
85
  });
78
86
  it("returns an expired record rather than hiding it", async () => {
79
- await store.save(record({ tokenHash: "hash-expired", expiresAt: Date.now() - 1000 }));
80
- const fetched = await store.get("hash-expired");
87
+ await store.save(record({ tokenHash: hash("expired"), expiresAt: Date.now() - 1000 }));
88
+ const fetched = await store.get(hash("expired"));
81
89
  assert(fetched !== null, "expiry is the service's call — it reports `expired` distinctly " +
82
90
  "from `invalid`, which it cannot do if the store swallows the row");
83
91
  });
84
92
  it("keeps records with different hashes independent", async () => {
85
- await store.save(record({ tokenHash: "hash-1" }));
86
- await store.save(record({ tokenHash: "hash-2" }));
87
- expect((await store.get("hash-1"))?.tokenHash).toBe("hash-1");
88
- expect((await store.get("hash-2"))?.tokenHash).toBe("hash-2");
93
+ await store.save(record({ tokenHash: hash("1") }));
94
+ await store.save(record({ tokenHash: hash("2") }));
95
+ expect((await store.get(hash("1")))?.tokenHash).toBe(hash("1"));
96
+ expect((await store.get(hash("2")))?.tokenHash).toBe(hash("2"));
89
97
  });
90
98
  });
91
99
  describe("markConsumed", () => {
92
100
  it("claims a live record and stamps it", async () => {
93
- await store.save(record({ tokenHash: "hash-1" }));
101
+ await store.save(record({ tokenHash: hash("1") }));
94
102
  const consumedAt = Date.now();
95
- expect(await store.markConsumed("hash-1", consumedAt)).toBe(true);
96
- expect((await store.get("hash-1"))?.consumedAt).toBe(consumedAt);
103
+ expect(await store.markConsumed(hash("1"), consumedAt)).toBe(true);
104
+ expect((await store.get(hash("1")))?.consumedAt).toBe(consumedAt);
97
105
  });
98
106
  it("refuses a record already consumed, keeping the first stamp", async () => {
99
- await store.save(record({ tokenHash: "hash-1" }));
107
+ await store.save(record({ tokenHash: hash("1") }));
100
108
  const first = Date.now();
101
- await store.markConsumed("hash-1", first);
102
- expect(await store.markConsumed("hash-1", first + 5_000)).toBe(false);
103
- expect((await store.get("hash-1"))?.consumedAt, "a losing claim must not overwrite the winner's record").toBe(first);
109
+ await store.markConsumed(hash("1"), first);
110
+ expect(await store.markConsumed(hash("1"), first + 5_000)).toBe(false);
111
+ expect((await store.get(hash("1")))?.consumedAt, "a losing claim must not overwrite the winner's record").toBe(first);
104
112
  });
105
113
  it("refuses an unknown token hash", async () => {
106
- expect(await store.markConsumed("no-such-hash", Date.now())).toBe(false);
114
+ expect(await store.markConsumed(hash("unknown"), Date.now())).toBe(false);
107
115
  });
108
116
  it(`claims a token for exactly one of ${CONCURRENT_CALLERS} concurrent redemptions`, async () => {
109
- await store.save(record({ tokenHash: "hash-1" }));
117
+ await store.save(record({ tokenHash: hash("1") }));
110
118
  const now = Date.now();
111
- const results = await race((caller) => store.markConsumed("hash-1", now + caller));
119
+ const results = await race((caller) => store.markConsumed(hash("1"), now + caller));
112
120
  expect(countTrue(results), "a link is single-use — requests racing one link must not each be " +
113
121
  "told they claimed it").toBe(1);
114
122
  });
115
123
  it("claims both tokens when two different ones are redeemed concurrently", async () => {
116
- await store.save(record({ tokenHash: "hash-1" }));
117
- await store.save(record({ tokenHash: "hash-2" }));
124
+ await store.save(record({ tokenHash: hash("1") }));
125
+ await store.save(record({ tokenHash: hash("2") }));
118
126
  const now = Date.now();
119
127
  const results = await Promise.all([
120
- store.markConsumed("hash-1", now),
121
- store.markConsumed("hash-2", now),
128
+ store.markConsumed(hash("1"), now),
129
+ store.markConsumed(hash("2"), now),
122
130
  ]);
123
131
  expect(countTrue(results)).toBe(2);
124
- assert((await store.get("hash-1"))?.consumedAt !== undefined &&
125
- (await store.get("hash-2"))?.consumedAt !== undefined, "neither stamp may be lost to the other's stale read");
132
+ assert((await store.get(hash("1")))?.consumedAt !== undefined &&
133
+ (await store.get(hash("2")))?.consumedAt !== undefined, "neither stamp may be lost to the other's stale read");
126
134
  });
127
135
  });
128
136
  if (expectDeleteBySubject) {
@@ -134,33 +142,39 @@ export function runTokenFlowStoreContractTests(options) {
134
142
  "`deleteBySubject: false` to record the gap.");
135
143
  });
136
144
  it("drops the pending tokens for one (purpose, subject)", async () => {
137
- await store.save(record({ tokenHash: "hash-1" }));
138
- await store.save(record({ tokenHash: "hash-2" }));
145
+ await store.save(record({ tokenHash: hash("1") }));
146
+ await store.save(record({ tokenHash: hash("2") }));
139
147
  await store.deleteBySubject(PURPOSE, SUBJECT);
140
- expect(await store.get("hash-1")).toBe(null);
141
- expect(await store.get("hash-2")).toBe(null);
148
+ expect(await store.get(hash("1"))).toBe(null);
149
+ expect(await store.get(hash("2"))).toBe(null);
142
150
  });
143
151
  it("leaves other subjects and other purposes alone", async () => {
144
- await store.save(record({ tokenHash: "hash-mine" }));
145
- await store.save(record({ tokenHash: "hash-other-subject", subject: OTHER_SUBJECT }));
146
- await store.save(record({ tokenHash: "hash-other-purpose", purpose: OTHER_PURPOSE }));
152
+ await store.save(record({ tokenHash: hash("mine") }));
153
+ await store.save(record({
154
+ tokenHash: hash("other-subject"),
155
+ subject: OTHER_SUBJECT,
156
+ }));
157
+ await store.save(record({
158
+ tokenHash: hash("other-purpose"),
159
+ purpose: OTHER_PURPOSE,
160
+ }));
147
161
  await store.deleteBySubject(PURPOSE, SUBJECT);
148
- expect(await store.get("hash-mine")).toBe(null);
149
- assert((await store.get("hash-other-subject")) !== null, "another subject's token must survive");
150
- assert((await store.get("hash-other-purpose")) !== null, "the same subject's token for another purpose must survive");
162
+ expect(await store.get(hash("mine"))).toBe(null);
163
+ assert((await store.get(hash("other-subject"))) !== null, "another subject's token must survive");
164
+ assert((await store.get(hash("other-purpose"))) !== null, "the same subject's token for another purpose must survive");
151
165
  });
152
166
  it("is a no-op when the pair has no tokens", async () => {
153
- await store.save(record({ tokenHash: "hash-1" }));
167
+ await store.save(record({ tokenHash: hash("1") }));
154
168
  await store.deleteBySubject(PURPOSE, OTHER_SUBJECT);
155
- assert((await store.get("hash-1")) !== null);
169
+ assert((await store.get(hash("1"))) !== null);
156
170
  });
157
171
  it("keeps an already-consumed record so it still reads as consumed", async () => {
158
- await store.save(record({ tokenHash: "hash-used" }));
159
- await store.save(record({ tokenHash: "hash-pending" }));
160
- expect(await store.markConsumed("hash-used", Date.now())).toBe(true);
172
+ await store.save(record({ tokenHash: hash("used") }));
173
+ await store.save(record({ tokenHash: hash("pending") }));
174
+ expect(await store.markConsumed(hash("used"), Date.now())).toBe(true);
161
175
  await store.deleteBySubject(PURPOSE, SUBJECT);
162
- expect(await store.get("hash-pending"), "the pending token must be dropped").toBe(null);
163
- const used = await store.get("hash-used");
176
+ expect(await store.get(hash("pending")), "the pending token must be dropped").toBe(null);
177
+ const used = await store.get(hash("used"));
164
178
  assert(used !== null, "a consumed record must survive so inspect() can answer " +
165
179
  "`consumed` rather than the generic `invalid`");
166
180
  assert(used.consumedAt !== undefined, "the surviving record must still carry its consumedAt stamp");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.38.0",
3
+ "version": "0.39.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
  "keywords": [
6
6
  "authorization-server",