@proteinjs/user-server 1.22.0 → 1.22.1

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/src/authentication/PasswordResetToken.d.ts +43 -12
  6. package/dist/src/authentication/PasswordResetToken.d.ts.map +1 -1
  7. package/dist/src/authentication/PasswordResetToken.js +86 -21
  8. package/dist/src/authentication/PasswordResetToken.js.map +1 -1
  9. package/dist/src/routes/devLogin.d.ts +1 -1
  10. package/dist/src/routes/devLogin.js +1 -1
  11. package/dist/src/routes/executePasswordReset.d.ts +2 -1
  12. package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
  13. package/dist/src/routes/executePasswordReset.js +9 -7
  14. package/dist/src/routes/executePasswordReset.js.map +1 -1
  15. package/dist/src/routes/initiatePasswordReset.d.ts +3 -2
  16. package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
  17. package/dist/src/routes/initiatePasswordReset.js +30 -27
  18. package/dist/src/routes/initiatePasswordReset.js.map +1 -1
  19. package/dist/test/DbTraffic.d.ts +29 -0
  20. package/dist/test/DbTraffic.d.ts.map +1 -0
  21. package/dist/test/DbTraffic.js +106 -0
  22. package/dist/test/DbTraffic.js.map +1 -0
  23. package/dist/test/DevLoginBootstrapAdmin.test.js +3 -4
  24. package/dist/test/DevLoginBootstrapAdmin.test.js.map +1 -1
  25. package/dist/test/DevLoginStaleCookieFirstHit.test.js +1 -1
  26. package/dist/test/DevLoginStaleCookieFirstHit.test.js.map +1 -1
  27. package/dist/test/ExecutePasswordReset.test.js +513 -124
  28. package/dist/test/ExecutePasswordReset.test.js.map +1 -1
  29. package/dist/test/InitiatePasswordReset.test.d.ts +2 -0
  30. package/dist/test/InitiatePasswordReset.test.d.ts.map +1 -0
  31. package/dist/test/InitiatePasswordReset.test.js +415 -0
  32. package/dist/test/InitiatePasswordReset.test.js.map +1 -0
  33. package/dist/test/LogCapture.d.ts +22 -0
  34. package/dist/test/LogCapture.d.ts.map +1 -0
  35. package/dist/test/LogCapture.js +127 -0
  36. package/dist/test/LogCapture.js.map +1 -0
  37. package/dist/test/MalformedResetTokens.d.ts +11 -0
  38. package/dist/test/MalformedResetTokens.d.ts.map +1 -0
  39. package/dist/test/MalformedResetTokens.js +35 -0
  40. package/dist/test/MalformedResetTokens.js.map +1 -0
  41. package/dist/test/ValidateResetToken.test.js +156 -33
  42. package/dist/test/ValidateResetToken.test.js.map +1 -1
  43. package/dist/test/emulatorLock.test.d.ts +2 -0
  44. package/dist/test/emulatorLock.test.d.ts.map +1 -0
  45. package/dist/test/emulatorLock.test.js +32 -0
  46. package/dist/test/emulatorLock.test.js.map +1 -0
  47. package/dist/test/emulatorLockName.test.d.ts +2 -0
  48. package/dist/test/emulatorLockName.test.d.ts.map +1 -0
  49. package/dist/test/emulatorLockName.test.js +36 -0
  50. package/dist/test/emulatorLockName.test.js.map +1 -0
  51. package/generated/index.ts +1 -1
  52. package/index.ts +1 -1
  53. package/jest.config.js +2 -2
  54. package/package.json +3 -3
  55. package/src/authentication/PasswordResetToken.ts +74 -20
  56. package/src/routes/devLogin.ts +1 -1
  57. package/src/routes/executePasswordReset.ts +3 -2
  58. package/src/routes/initiatePasswordReset.ts +20 -17
  59. package/test/DbTraffic.ts +43 -0
  60. package/test/DevLoginBootstrapAdmin.test.ts +3 -4
  61. package/test/DevLoginStaleCookieFirstHit.test.ts +1 -1
  62. package/test/ExecutePasswordReset.test.ts +308 -70
  63. package/test/InitiatePasswordReset.test.ts +250 -0
  64. package/test/LogCapture.ts +54 -0
  65. package/test/MalformedResetTokens.ts +27 -0
  66. package/test/ValidateResetToken.test.ts +108 -24
  67. package/test/emulatorLock.js +5 -6
  68. package/test/emulatorLock.test.ts +32 -0
  69. package/test/emulatorLockName.test.ts +33 -0
@@ -1,11 +1,14 @@
1
+ import { createHash } from 'crypto';
1
2
  import moment, { Moment } from 'moment';
2
3
  import { getDbAsSystem } from '@proteinjs/db';
3
- import { Logger } from '@proteinjs/logger';
4
4
  import { tables, User } from '@proteinjs/user';
5
5
  import { executePasswordReset } from '../src/routes/executePasswordReset';
6
6
  import { PasswordHasher } from '../src/authentication/PasswordHasher';
7
7
  import { PasswordResetToken } from '../src/authentication/PasswordResetToken';
8
8
  import { UserServerTestEnvironment } from './UserServerTestEnvironment';
9
+ import { DbTraffic } from './DbTraffic';
10
+ import { LogCapture } from './LogCapture';
11
+ import { MalformedResetTokens } from './MalformedResetTokens';
9
12
 
10
13
  const testEnv = new UserServerTestEnvironment();
11
14
 
@@ -14,21 +17,36 @@ const testEnv = new UserServerTestEnvironment();
14
17
  * the lookup must never be built from anything but a well-formed token: a `null` in the body
15
18
  * renders as `IS NULL` and matches every account with no pending reset, an empty string matches
16
19
  * an emptied column, and other types reach the driver. Covered here, outcomes only (rows
17
- * written), against the Spanner emulator:
18
- * - an absent, null, empty or malformed token: 400, and no password in the table changes — an
19
- * account with no pending reset is never matched, whatever the request carries;
20
+ * written, statements run, lines logged), against the Spanner emulator:
21
+ * - an absent, null, empty or malformed token — every type a body can carry and every near-miss
22
+ * cut from a live token: 400, nothing in the table changes, and nothing reaches the database
23
+ * (no lookup built, no statement run); the live token still works afterwards. With a digest
24
+ * lookup a wrongly admitted value still matches no row and still answers 400, so the response
25
+ * alone proves nothing about the shape check — the absence of database traffic does;
26
+ * - a request with no body at all: 400, and nothing changes;
27
+ * - the row stores the token's SHA-256 digest, never the token: the stored digest presented as
28
+ * a token is refused — it has a token's shape, so one lookup is built, by the digest OF the
29
+ * presented value, and nothing is written — and so is a token an earlier release stored in clear;
20
30
  * - a live token resets the password once: the row verifies the new password, the token and its
21
31
  * expiry are cleared, and the same token presented again is refused;
32
+ * - two simultaneous presentations of one token: exactly one 200, and the row verifies only the
33
+ * winner's password;
22
34
  * - an expired token, or a token whose row carries no expiry, is refused;
23
35
  * - a blank new password is refused without consuming the token;
24
- * - the presented token never reaches the log;
25
- * - the token owner's own contract: mint shape, the stored-vs-presented match, liveness, and the
26
- * conditional redemption.
36
+ * - no token, no leading characters of a token and no full digest reach the log, on any path
37
+ * through the route;
38
+ * - the token owner's own contract: mint shape and what it stores, the shape refusal, the
39
+ * stored-vs-presented digest match, liveness, the conditional redemption (one at a time and
40
+ * concurrent), the withdrawal, the mint time, and the log reference.
41
+ *
42
+ * Every live token is seeded through `PasswordResetToken.mint`, so the suite moves with what the
43
+ * owner stores. Only the two named column states the owner never writes are seeded raw: an
44
+ * emptied column, and a token stored in clear by a release that predates the digest.
27
45
  */
28
46
 
29
47
  type RouteOutcome = { status: number; body?: any };
30
48
 
31
- const invokeExecute = async (body: Record<string, unknown>): Promise<RouteOutcome> => {
49
+ const invokeExecuteRequest = async (request: Record<string, unknown>): Promise<RouteOutcome> => {
32
50
  const outcome: RouteOutcome = { status: 200 };
33
51
  const response = {
34
52
  status(code: number) {
@@ -39,35 +57,68 @@ const invokeExecute = async (body: Record<string, unknown>): Promise<RouteOutcom
39
57
  outcome.body = body;
40
58
  },
41
59
  };
42
- await executePasswordReset.onRequest({ body } as never, response as never);
60
+ await executePasswordReset.onRequest(request as never, response as never);
43
61
  return outcome;
44
62
  };
45
63
 
46
- const mintToken = () => new PasswordResetToken().mint();
64
+ const invokeExecute = async (body: Record<string, unknown>) => await invokeExecuteRequest({ body });
47
65
 
48
- const armResetToken = async (email: string, token: string | null, expiration: Moment | null): Promise<User> => {
66
+ /** The digest a row must hold for `token`, computed here independently of the owner. */
67
+ const sha256Hex = (value: string) => createHash('sha256').update(value).digest('hex');
68
+
69
+ /** A well-formed token that was never issued to anyone. */
70
+ const unissuedToken = () => sha256Hex(`never issued ${Math.random()}`);
71
+
72
+ /** A user with a token minted by the owner; `expiration` (when given) replaces the expiry the mint stored. */
73
+ const armResetToken = async (email: string, expiration?: Moment | null): Promise<{ user: User; token: string }> => {
74
+ const user = await testEnv.createUser({ name: 'Reset User', email });
75
+ const token = await new PasswordResetToken().mint(user);
76
+ if (expiration !== undefined) {
77
+ await getDbAsSystem().update(tables.User, { id: user.id, passwordResetTokenExpiration: expiration });
78
+ }
79
+ return { user, token };
80
+ };
81
+
82
+ /** A user whose token column is written raw — only for the column states the owner never writes. */
83
+ const armRawTokenColumn = async (email: string, stored: string): Promise<User> => {
49
84
  const user = await testEnv.createUser({ name: 'Reset User', email });
50
85
  await getDbAsSystem().update(tables.User, {
51
86
  id: user.id,
52
- passwordResetToken: token,
53
- passwordResetTokenExpiration: expiration,
87
+ passwordResetToken: stored,
88
+ passwordResetTokenExpiration: moment().add(1, 'hour'),
54
89
  });
55
90
  return user;
56
91
  };
57
92
 
58
93
  const userRow = async (id: string) => await getDbAsSystem().get(tables.User, { id });
59
94
 
60
- /** Every password in the table, by email — the whole-table outcome a refused request must leave untouched. */
61
- const passwordsByEmail = async (): Promise<Record<string, string>> => {
62
- const byEmail: Record<string, string> = {};
95
+ /** Every password, stored token and expiry in the table, by email — the whole-table outcome a refused request must leave untouched. */
96
+ const resetStateByEmail = async (): Promise<Record<string, unknown[]>> => {
97
+ const byEmail: Record<string, unknown[]> = {};
63
98
  for (const user of await getDbAsSystem().query(tables.User, {})) {
64
- byEmail[user.email] = user.password;
99
+ byEmail[user.email] = [
100
+ user.password,
101
+ user.passwordResetToken ?? null,
102
+ user.passwordResetTokenExpiration ? moment(user.passwordResetTokenExpiration).toISOString() : null,
103
+ ];
65
104
  }
66
105
  return byEmail;
67
106
  };
68
107
 
108
+ /** Which of `candidates` the stored password hash verifies. */
109
+ const verifiedPasswords = async (storedHash: string, candidates: string[]): Promise<string[]> => {
110
+ const verified: string[] = [];
111
+ for (const candidate of candidates) {
112
+ if (await new PasswordHasher().verify(storedHash, candidate)) {
113
+ verified.push(candidate);
114
+ }
115
+ }
116
+ return verified;
117
+ };
118
+
69
119
  type TokenInternals = {
70
- matches(stored: string | null | undefined, presented: string): boolean;
120
+ digest(token: string): string;
121
+ matches(storedDigest: string | null | undefined, presentedDigest: string): boolean;
71
122
  isLive(expiration: Moment | null | undefined): boolean;
72
123
  };
73
124
 
@@ -77,7 +128,7 @@ describe('executePasswordReset route', () => {
77
128
  // No pending reset — the account a `null` token's `IS NULL` filter would match.
78
129
  await testEnv.createUser({ name: 'Idle User', email: 'reset-idle@test.local' });
79
130
  // An emptied token column — the account an empty token would match.
80
- await armResetToken('reset-emptied@test.local', '', moment().add(1, 'hour'));
131
+ await armRawTokenColumn('reset-emptied@test.local', '');
81
132
  });
82
133
 
83
134
  afterAll(async () => {
@@ -85,28 +136,55 @@ describe('executePasswordReset route', () => {
85
136
  });
86
137
 
87
138
  describe('a request without a well-formed token is refused before any lookup', () => {
88
- it.each([
89
- ['absent', {}],
90
- ['null', { token: null }],
91
- ['empty', { token: '' }],
92
- ['not a token string', { token: 'tok-valid-1' }],
93
- ['a number', { token: 123 }],
94
- ['an array', { token: ['a', 'b'] }],
95
- ['an object', { token: { passwordResetToken: null } }],
96
- ])('%s: 400, and no password in the table changes', async (_label, tokenField) => {
97
- const before = await passwordsByEmail();
98
-
99
- const outcome = await invokeExecute({ ...tokenField, newPassword: 'hijacked' });
139
+ let liveUser: User;
140
+ let liveToken: string;
100
141
 
101
- expect(outcome.status).toBe(400);
102
- expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
103
- expect(await passwordsByEmail()).toEqual(before);
142
+ beforeAll(async () => {
143
+ ({ user: liveUser, token: liveToken } = await armResetToken('reset-shapes@test.local'));
104
144
  });
145
+
146
+ it.each(MalformedResetTokens.CASES)(
147
+ '%s: 400, nothing in the table changes, and nothing reaches the database',
148
+ async (_label, tokenField) => {
149
+ const before = await resetStateByEmail();
150
+
151
+ const { result: outcome, traffic } = await DbTraffic.during(
152
+ testEnv.spannerDriver,
153
+ async () => await invokeExecute({ ...tokenField(liveToken), newPassword: 'hijacked' })
154
+ );
155
+
156
+ expect(outcome.status).toBe(400);
157
+ expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
158
+ expect(traffic).toEqual(DbTraffic.NONE);
159
+ expect(await resetStateByEmail()).toEqual(before);
160
+ }
161
+ );
162
+
163
+ it('after every refusal the live token still resets the password', async () => {
164
+ const outcome = await invokeExecute({ token: liveToken, newPassword: 'still live' });
165
+
166
+ expect(outcome.status).toBe(200);
167
+ const row = await userRow(liveUser.id);
168
+ await expect(new PasswordHasher().verify(row.password, 'still live')).resolves.toBe(true);
169
+ });
170
+ });
171
+
172
+ it.each([
173
+ ['no body', 'reset-no-body@test.local', {}],
174
+ ['a null body', 'reset-null-body@test.local', { body: null }],
175
+ ])('a request with %s: 400, and nothing changes', async (_label, email, request) => {
176
+ const { user, token } = await armResetToken(email);
177
+ const before = await resetStateByEmail();
178
+
179
+ const outcome = await invokeExecuteRequest(request);
180
+
181
+ expect(outcome.status).toBe(400);
182
+ expect(await resetStateByEmail()).toEqual(before);
183
+ expect((await userRow(user.id)).passwordResetToken).toBe(sha256Hex(token));
105
184
  });
106
185
 
107
186
  it('a live token resets the password once: the row verifies it, the token clears, a re-presentation is refused', async () => {
108
- const token = mintToken();
109
- const user = await armResetToken('reset-live@test.local', token, moment().add(1, 'hour'));
187
+ const { user, token } = await armResetToken('reset-live@test.local');
110
188
 
111
189
  const first = await invokeExecute({ token, newPassword: 'first new password' });
112
190
 
@@ -123,9 +201,79 @@ describe('executePasswordReset route', () => {
123
201
  expect((await userRow(user.id)).password).toBe(afterFirst.password);
124
202
  });
125
203
 
204
+ it("two simultaneous presentations of one token: exactly one 200, and the row verifies only the winner's password", async () => {
205
+ const { user, token } = await armResetToken('reset-race@test.local');
206
+ const candidates = ['racer one password', 'racer two password'];
207
+ // Both requests resolve the token as live and hash their password; the barrier then releases
208
+ // them into the redemption together, so neither has written when the other arrives.
209
+ const hashPassword = PasswordHasher.prototype.hash;
210
+ let release: () => void = () => undefined;
211
+ const bothHashed = new Promise<void>((resolve) => (release = resolve));
212
+ let hashed = 0;
213
+ const hash = jest.spyOn(PasswordHasher.prototype, 'hash').mockImplementation(async function (
214
+ this: PasswordHasher,
215
+ password: string
216
+ ) {
217
+ const hashedPassword = await hashPassword.call(this, password);
218
+ if (++hashed === candidates.length) {
219
+ release();
220
+ }
221
+ await bothHashed;
222
+ return hashedPassword;
223
+ });
224
+ let outcomes: RouteOutcome[];
225
+ try {
226
+ outcomes = await Promise.all(candidates.map((newPassword) => invokeExecute({ token, newPassword })));
227
+ } finally {
228
+ hash.mockRestore();
229
+ }
230
+
231
+ expect(outcomes.map((outcome) => outcome.status).sort()).toEqual([200, 400]);
232
+ const winner = candidates[outcomes.findIndex((outcome) => outcome.status === 200)];
233
+ const row = await userRow(user.id);
234
+ expect(await verifiedPasswords(row.password, candidates)).toEqual([winner]);
235
+ expect(row.passwordResetToken).toBeNull();
236
+ expect(row.passwordResetTokenExpiration).toBeNull();
237
+ });
238
+
239
+ it('the row stores the SHA-256 digest of the token, never the token — and the digest presented as a token is refused', async () => {
240
+ const { user, token } = await armResetToken('reset-digest@test.local');
241
+ const armed = await userRow(user.id);
242
+
243
+ expect(armed.passwordResetToken).toBe(sha256Hex(token));
244
+ expect(JSON.stringify(armed)).not.toContain(token);
245
+
246
+ // A digest has a token's shape, so this presentation does reach the lookup — which is built
247
+ // from the digest OF the presented value, so what the row stores never equals it.
248
+ const { result: outcome, traffic } = await DbTraffic.during(
249
+ testEnv.spannerDriver,
250
+ async () => await invokeExecute({ token: armed.passwordResetToken, newPassword: 'hijacked' })
251
+ );
252
+
253
+ expect(outcome.status).toBe(400);
254
+ expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
255
+ expect(traffic.lookups).toEqual([{ passwordResetToken: sha256Hex(sha256Hex(token)) }]);
256
+ expect(traffic.statements).toBe(1);
257
+ expect(traffic.writes).toBe(0);
258
+ const row = await userRow(user.id);
259
+ expect(row.password).toBe(armed.password);
260
+ expect(row.passwordResetToken).toBe(sha256Hex(token));
261
+ });
262
+
263
+ it('a token an earlier release stored in clear is refused — it fails for the rest of its hour, no migration', async () => {
264
+ const clearToken = unissuedToken();
265
+ const user = await armRawTokenColumn('reset-stored-in-clear@test.local', clearToken);
266
+ const before = (await userRow(user.id)).password;
267
+
268
+ const outcome = await invokeExecute({ token: clearToken, newPassword: 'hijacked' });
269
+
270
+ expect(outcome.status).toBe(400);
271
+ expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
272
+ expect((await userRow(user.id)).password).toBe(before);
273
+ });
274
+
126
275
  it('an expired token is refused and changes nothing', async () => {
127
- const token = mintToken();
128
- const user = await armResetToken('reset-expired@test.local', token, moment().subtract(1, 'minute'));
276
+ const { user, token } = await armResetToken('reset-expired@test.local', moment().subtract(1, 'minute'));
129
277
  const before = (await userRow(user.id)).password;
130
278
 
131
279
  const outcome = await invokeExecute({ token, newPassword: 'hijacked' });
@@ -134,12 +282,11 @@ describe('executePasswordReset route', () => {
134
282
  expect(outcome.body).toEqual({ error: 'Reset token has expired' });
135
283
  const row = await userRow(user.id);
136
284
  expect(row.password).toBe(before);
137
- expect(row.passwordResetToken).toBe(token);
285
+ expect(row.passwordResetToken).toBe(sha256Hex(token));
138
286
  });
139
287
 
140
288
  it('a token whose row carries no expiry is refused — never live by omission', async () => {
141
- const token = mintToken();
142
- const user = await armResetToken('reset-no-expiry@test.local', token, null);
289
+ const { user, token } = await armResetToken('reset-no-expiry@test.local', null);
143
290
  const before = (await userRow(user.id)).password;
144
291
 
145
292
  const outcome = await invokeExecute({ token, newPassword: 'hijacked' });
@@ -149,8 +296,7 @@ describe('executePasswordReset route', () => {
149
296
  });
150
297
 
151
298
  it('a blank new password is refused without consuming the token', async () => {
152
- const token = mintToken();
153
- const user = await armResetToken('reset-blank@test.local', token, moment().add(1, 'hour'));
299
+ const { user, token } = await armResetToken('reset-blank@test.local');
154
300
  const before = (await userRow(user.id)).password;
155
301
 
156
302
  for (const newPassword of ['', undefined, 42]) {
@@ -160,42 +306,98 @@ describe('executePasswordReset route', () => {
160
306
 
161
307
  const row = await userRow(user.id);
162
308
  expect(row.password).toBe(before);
163
- expect(row.passwordResetToken).toBe(token);
309
+ expect(row.passwordResetToken).toBe(sha256Hex(token));
164
310
  });
165
311
 
166
- it('the presented token never reaches the log', async () => {
167
- const token = mintToken(); // well-formed, never issued
168
- const info = jest.spyOn(Logger.prototype, 'info');
169
- try {
170
- await invokeExecute({ token, newPassword: 'hijacked' });
312
+ it('no token, no leading characters of one and no full digest reach the log — every path through the route', async () => {
313
+ const { token: live } = await armResetToken('reset-log-live@test.local');
314
+ const { token: expired } = await armResetToken('reset-log-expired@test.local', moment().subtract(1, 'minute'));
315
+ const unissued = unissuedToken();
316
+
317
+ const log = await LogCapture.during(async () => {
318
+ await invokeExecute({ token: unissued, newPassword: 'hijacked' });
319
+ await invokeExecute({ token: live.toUpperCase(), newPassword: 'hijacked' });
320
+ await invokeExecute({ token: sha256Hex(live), newPassword: 'hijacked' });
321
+ await invokeExecute({ token: expired, newPassword: 'hijacked' });
322
+ await invokeExecute({ token: live, newPassword: 'a logged password' });
323
+ await invokeExecute({ token: live, newPassword: 'a second logged password' });
324
+ });
171
325
 
172
- expect(info).toHaveBeenCalled();
173
- for (const [entry] of info.mock.calls) {
174
- expect(JSON.stringify(entry)).not.toContain(token);
175
- }
176
- } finally {
177
- info.mockRestore();
326
+ // The capture saw the route's lines: each path above logged.
327
+ expect(log.text).toContain('Invalid reset token used');
328
+ expect(log.text).toContain('Expired reset token used');
329
+ expect(log.text).toContain('Password successfully reset');
330
+ expect(log.lines.length).toBeGreaterThanOrEqual(6);
331
+ // The digest presented as a token is covered by `live`: its full digest is what was presented.
332
+ for (const token of [unissued, live, expired]) {
333
+ log.expectFreeOf(token);
178
334
  }
179
335
  });
180
336
 
181
337
  describe('PasswordResetToken', () => {
182
338
  const internals = new PasswordResetToken() as unknown as TokenInternals;
183
339
 
184
- it('mints 64 lowercase hex characters, fresh each time', () => {
185
- const token = mintToken();
340
+ it('mints 64 lowercase hex characters, fresh each time, and stores the digest with an expiry an hour out', async () => {
341
+ const user = await testEnv.createUser({ name: 'Reset User', email: 'reset-mint@test.local' });
342
+
343
+ const first = await new PasswordResetToken().mint(user);
344
+ const afterFirst = await userRow(user.id);
345
+ const second = await new PasswordResetToken().mint(user);
346
+ const afterSecond = await userRow(user.id);
347
+
348
+ expect(first).toMatch(/^[0-9a-f]{64}$/);
349
+ expect(second).toMatch(/^[0-9a-f]{64}$/);
350
+ expect(second).not.toBe(first);
351
+ expect(afterFirst.passwordResetToken).toBe(sha256Hex(first));
352
+ expect(afterSecond.passwordResetToken).toBe(sha256Hex(second));
353
+ const minutesOut = moment(afterSecond.passwordResetTokenExpiration).diff(moment(), 'minutes', true);
354
+ expect(minutesOut).toBeGreaterThan(59);
355
+ expect(minutesOut).toBeLessThanOrEqual(60);
356
+ });
357
+
358
+ it.each(MalformedResetTokens.CASES)(
359
+ 'resolve refuses %s as malformed without touching the database, and gives it no log reference',
360
+ async (_label, tokenField) => {
361
+ const presented = tokenField(unissuedToken()).token;
362
+
363
+ const { result: resolution, traffic } = await DbTraffic.during(
364
+ testEnv.spannerDriver,
365
+ async () => await new PasswordResetToken().resolve(presented)
366
+ );
367
+
368
+ expect(resolution).toEqual({ status: 'malformed' });
369
+ expect(traffic).toEqual(DbTraffic.NONE);
370
+ expect(new PasswordResetToken().fingerprint(presented)).toBeUndefined();
371
+ }
372
+ );
373
+
374
+ it('the log reference to a token is cut from its digest, never from the token: the same token reads the same, another reads differently', () => {
375
+ const token = unissuedToken();
376
+ const another = unissuedToken();
377
+
378
+ const reference = new PasswordResetToken().fingerprint(token);
186
379
 
187
- expect(token).toMatch(/^[0-9a-f]{64}$/);
188
- expect(mintToken()).not.toBe(token);
380
+ expect(reference).toBe(sha256Hex(token).slice(0, 12));
381
+ expect(reference).not.toBe(token.slice(0, 12));
382
+ expect(new PasswordResetToken().fingerprint(token)).toBe(reference);
383
+ expect(new PasswordResetToken().fingerprint(another)).not.toBe(reference);
189
384
  });
190
385
 
191
- it('a stored token matches only the same string — never an absent or emptied one', () => {
192
- const token = mintToken();
386
+ it('the digest is the SHA-256 of the token, hex-encoded', () => {
387
+ const token = unissuedToken();
193
388
 
194
- expect(internals.matches(token, token)).toBe(true);
195
- expect(internals.matches(mintToken(), token)).toBe(false);
196
- expect(internals.matches(null, token)).toBe(false);
197
- expect(internals.matches(undefined, token)).toBe(false);
198
- expect(internals.matches('', token)).toBe(false);
389
+ expect(internals.digest(token)).toBe(sha256Hex(token));
390
+ expect(internals.digest(token)).not.toBe(token);
391
+ });
392
+
393
+ it('a stored digest matches only the same digest — never an absent or emptied one', () => {
394
+ const digest = sha256Hex('one token');
395
+
396
+ expect(internals.matches(digest, digest)).toBe(true);
397
+ expect(internals.matches(sha256Hex('another token'), digest)).toBe(false);
398
+ expect(internals.matches(null, digest)).toBe(false);
399
+ expect(internals.matches(undefined, digest)).toBe(false);
400
+ expect(internals.matches('', digest)).toBe(false);
199
401
  });
200
402
 
201
403
  it('an expiry is live only as a valid timestamp in the future', () => {
@@ -206,12 +408,12 @@ describe('executePasswordReset route', () => {
206
408
  expect(internals.isLive(moment.invalid())).toBe(false);
207
409
  });
208
410
 
209
- it('redeem writes only while the row still carries the token', async () => {
210
- const live = mintToken();
211
- const user = await armResetToken('reset-redeem@test.local', live, moment().add(1, 'hour'));
411
+ it("redeem writes only while the row still carries the token's digest", async () => {
412
+ const { user, token: live } = await armResetToken('reset-redeem@test.local');
212
413
  const before = (await userRow(user.id)).password;
213
414
 
214
- await expect(new PasswordResetToken().redeem(user, mintToken(), 'other-hash')).resolves.toBe(false);
415
+ await expect(new PasswordResetToken().redeem(user, unissuedToken(), 'other-hash')).resolves.toBe(false);
416
+ await expect(new PasswordResetToken().redeem(user, sha256Hex(live), 'other-hash')).resolves.toBe(false);
215
417
  expect((await userRow(user.id)).password).toBe(before);
216
418
 
217
419
  await expect(new PasswordResetToken().redeem(user, live, 'new-hash')).resolves.toBe(true);
@@ -220,5 +422,41 @@ describe('executePasswordReset route', () => {
220
422
  expect(row.passwordResetToken).toBeNull();
221
423
  expect(row.passwordResetTokenExpiration).toBeNull();
222
424
  });
425
+
426
+ it('concurrent redeems of one token: exactly one resolves true, and the row holds the winner', async () => {
427
+ const { user, token } = await armResetToken('reset-redeem-race@test.local');
428
+ const hashes = ['hash-1', 'hash-2', 'hash-3', 'hash-4', 'hash-5', 'hash-6', 'hash-7', 'hash-8'];
429
+
430
+ const results = await Promise.all(hashes.map((hash) => new PasswordResetToken().redeem(user, token, hash)));
431
+
432
+ expect(results.filter((redeemed) => redeemed)).toHaveLength(1);
433
+ const row = await userRow(user.id);
434
+ expect(row.password).toBe(hashes[results.indexOf(true)]);
435
+ expect(row.passwordResetToken).toBeNull();
436
+ });
437
+
438
+ it('revoke clears the row only while it still carries the token — a newer token is left alone', async () => {
439
+ const { user, token: withdrawn } = await armResetToken('reset-revoke@test.local');
440
+ const newer = await new PasswordResetToken().mint(user);
441
+
442
+ await new PasswordResetToken().revoke(user, withdrawn);
443
+ expect((await userRow(user.id)).passwordResetToken).toBe(sha256Hex(newer));
444
+
445
+ await new PasswordResetToken().revoke(user, newer);
446
+ const row = await userRow(user.id);
447
+ expect(row.passwordResetToken).toBeNull();
448
+ expect(row.passwordResetTokenExpiration).toBeNull();
449
+ expect(row.password).toBe('test');
450
+ });
451
+
452
+ it('mintedAt is the moment the outstanding token was minted — undefined when the row carries none', async () => {
453
+ const idle = await testEnv.createUser({ name: 'Reset User', email: 'reset-minted-at-idle@test.local' });
454
+ const { user } = await armResetToken('reset-minted-at@test.local');
455
+
456
+ expect(new PasswordResetToken().mintedAt(idle)).toBeUndefined();
457
+ const mintedAt = new PasswordResetToken().mintedAt(await userRow(user.id));
458
+ expect(mintedAt).toBeDefined();
459
+ expect(Math.abs(moment().diff(mintedAt, 'seconds'))).toBeLessThan(30);
460
+ });
223
461
  });
224
462
  });