@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
@@ -50,16 +50,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
50
50
  return (mod && mod.__esModule) ? mod : { "default": mod };
51
51
  };
52
52
  Object.defineProperty(exports, "__esModule", { value: true });
53
+ var crypto_1 = require("crypto");
53
54
  var moment_1 = __importDefault(require("moment"));
54
55
  var db_1 = require("@proteinjs/db");
55
- var logger_1 = require("@proteinjs/logger");
56
56
  var user_1 = require("@proteinjs/user");
57
57
  var executePasswordReset_1 = require("../src/routes/executePasswordReset");
58
58
  var PasswordHasher_1 = require("../src/authentication/PasswordHasher");
59
59
  var PasswordResetToken_1 = require("../src/authentication/PasswordResetToken");
60
60
  var UserServerTestEnvironment_1 = require("./UserServerTestEnvironment");
61
+ var DbTraffic_1 = require("./DbTraffic");
62
+ var LogCapture_1 = require("./LogCapture");
63
+ var MalformedResetTokens_1 = require("./MalformedResetTokens");
61
64
  var testEnv = new UserServerTestEnvironment_1.UserServerTestEnvironment();
62
- var invokeExecute = function (body) { return __awaiter(void 0, void 0, void 0, function () {
65
+ var invokeExecuteRequest = function (request) { return __awaiter(void 0, void 0, void 0, function () {
63
66
  var outcome, response;
64
67
  return __generator(this, function (_a) {
65
68
  switch (_a.label) {
@@ -74,15 +77,45 @@ var invokeExecute = function (body) { return __awaiter(void 0, void 0, void 0, f
74
77
  outcome.body = body;
75
78
  },
76
79
  };
77
- return [4 /*yield*/, executePasswordReset_1.executePasswordReset.onRequest({ body: body }, response)];
80
+ return [4 /*yield*/, executePasswordReset_1.executePasswordReset.onRequest(request, response)];
78
81
  case 1:
79
82
  _a.sent();
80
83
  return [2 /*return*/, outcome];
81
84
  }
82
85
  });
83
86
  }); };
84
- var mintToken = function () { return new PasswordResetToken_1.PasswordResetToken().mint(); };
85
- var armResetToken = function (email, token, expiration) { return __awaiter(void 0, void 0, void 0, function () {
87
+ var invokeExecute = function (body) { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
88
+ switch (_a.label) {
89
+ case 0: return [4 /*yield*/, invokeExecuteRequest({ body: body })];
90
+ case 1: return [2 /*return*/, _a.sent()];
91
+ }
92
+ }); }); };
93
+ /** The digest a row must hold for `token`, computed here independently of the owner. */
94
+ var sha256Hex = function (value) { return (0, crypto_1.createHash)('sha256').update(value).digest('hex'); };
95
+ /** A well-formed token that was never issued to anyone. */
96
+ var unissuedToken = function () { return sha256Hex("never issued ".concat(Math.random())); };
97
+ /** A user with a token minted by the owner; `expiration` (when given) replaces the expiry the mint stored. */
98
+ var armResetToken = function (email, expiration) { return __awaiter(void 0, void 0, void 0, function () {
99
+ var user, token;
100
+ return __generator(this, function (_a) {
101
+ switch (_a.label) {
102
+ case 0: return [4 /*yield*/, testEnv.createUser({ name: 'Reset User', email: email })];
103
+ case 1:
104
+ user = _a.sent();
105
+ return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().mint(user)];
106
+ case 2:
107
+ token = _a.sent();
108
+ if (!(expiration !== undefined)) return [3 /*break*/, 4];
109
+ return [4 /*yield*/, (0, db_1.getDbAsSystem)().update(user_1.tables.User, { id: user.id, passwordResetTokenExpiration: expiration })];
110
+ case 3:
111
+ _a.sent();
112
+ _a.label = 4;
113
+ case 4: return [2 /*return*/, { user: user, token: token }];
114
+ }
115
+ });
116
+ }); };
117
+ /** A user whose token column is written raw — only for the column states the owner never writes. */
118
+ var armRawTokenColumn = function (email, stored) { return __awaiter(void 0, void 0, void 0, function () {
86
119
  var user;
87
120
  return __generator(this, function (_a) {
88
121
  switch (_a.label) {
@@ -91,8 +124,8 @@ var armResetToken = function (email, token, expiration) { return __awaiter(void
91
124
  user = _a.sent();
92
125
  return [4 /*yield*/, (0, db_1.getDbAsSystem)().update(user_1.tables.User, {
93
126
  id: user.id,
94
- passwordResetToken: token,
95
- passwordResetTokenExpiration: expiration,
127
+ passwordResetToken: stored,
128
+ passwordResetTokenExpiration: (0, moment_1.default)().add(1, 'hour'),
96
129
  })];
97
130
  case 2:
98
131
  _a.sent();
@@ -106,23 +139,28 @@ var userRow = function (id) { return __awaiter(void 0, void 0, void 0, function
106
139
  case 1: return [2 /*return*/, _a.sent()];
107
140
  }
108
141
  }); }); };
109
- /** Every password in the table, by email — the whole-table outcome a refused request must leave untouched. */
110
- var passwordsByEmail = function () { return __awaiter(void 0, void 0, void 0, function () {
142
+ /** Every password, stored token and expiry in the table, by email — the whole-table outcome a refused request must leave untouched. */
143
+ var resetStateByEmail = function () { return __awaiter(void 0, void 0, void 0, function () {
111
144
  var byEmail, _i, _a, user;
112
- return __generator(this, function (_b) {
113
- switch (_b.label) {
145
+ var _b;
146
+ return __generator(this, function (_c) {
147
+ switch (_c.label) {
114
148
  case 0:
115
149
  byEmail = {};
116
150
  _i = 0;
117
151
  return [4 /*yield*/, (0, db_1.getDbAsSystem)().query(user_1.tables.User, {})];
118
152
  case 1:
119
- _a = _b.sent();
120
- _b.label = 2;
153
+ _a = _c.sent();
154
+ _c.label = 2;
121
155
  case 2:
122
156
  if (!(_i < _a.length)) return [3 /*break*/, 4];
123
157
  user = _a[_i];
124
- byEmail[user.email] = user.password;
125
- _b.label = 3;
158
+ byEmail[user.email] = [
159
+ user.password,
160
+ (_b = user.passwordResetToken) !== null && _b !== void 0 ? _b : null,
161
+ user.passwordResetTokenExpiration ? (0, moment_1.default)(user.passwordResetTokenExpiration).toISOString() : null,
162
+ ];
163
+ _c.label = 3;
126
164
  case 3:
127
165
  _i++;
128
166
  return [3 /*break*/, 2];
@@ -130,6 +168,31 @@ var passwordsByEmail = function () { return __awaiter(void 0, void 0, void 0, fu
130
168
  }
131
169
  });
132
170
  }); };
171
+ /** Which of `candidates` the stored password hash verifies. */
172
+ var verifiedPasswords = function (storedHash, candidates) { return __awaiter(void 0, void 0, void 0, function () {
173
+ var verified, _i, candidates_1, candidate;
174
+ return __generator(this, function (_a) {
175
+ switch (_a.label) {
176
+ case 0:
177
+ verified = [];
178
+ _i = 0, candidates_1 = candidates;
179
+ _a.label = 1;
180
+ case 1:
181
+ if (!(_i < candidates_1.length)) return [3 /*break*/, 4];
182
+ candidate = candidates_1[_i];
183
+ return [4 /*yield*/, new PasswordHasher_1.PasswordHasher().verify(storedHash, candidate)];
184
+ case 2:
185
+ if (_a.sent()) {
186
+ verified.push(candidate);
187
+ }
188
+ _a.label = 3;
189
+ case 3:
190
+ _i++;
191
+ return [3 /*break*/, 1];
192
+ case 4: return [2 /*return*/, verified];
193
+ }
194
+ });
195
+ }); };
133
196
  describe('executePasswordReset route', function () {
134
197
  beforeAll(function () { return __awaiter(void 0, void 0, void 0, function () {
135
198
  return __generator(this, function (_a) {
@@ -143,7 +206,7 @@ describe('executePasswordReset route', function () {
143
206
  // No pending reset — the account a `null` token's `IS NULL` filter would match.
144
207
  _a.sent();
145
208
  // An emptied token column — the account an empty token would match.
146
- return [4 /*yield*/, armResetToken('reset-emptied@test.local', '', (0, moment_1.default)().add(1, 'hour'))];
209
+ return [4 /*yield*/, armRawTokenColumn('reset-emptied@test.local', '')];
147
210
  case 3:
148
211
  // An emptied token column — the account an empty token would match.
149
212
  _a.sent();
@@ -162,111 +225,233 @@ describe('executePasswordReset route', function () {
162
225
  });
163
226
  }); });
164
227
  describe('a request without a well-formed token is refused before any lookup', function () {
165
- it.each([
166
- ['absent', {}],
167
- ['null', { token: null }],
168
- ['empty', { token: '' }],
169
- ['not a token string', { token: 'tok-valid-1' }],
170
- ['a number', { token: 123 }],
171
- ['an array', { token: ['a', 'b'] }],
172
- ['an object', { token: { passwordResetToken: null } }],
173
- ])('%s: 400, and no password in the table changes', function (_label, tokenField) { return __awaiter(void 0, void 0, void 0, function () {
174
- var before, outcome, _a;
228
+ var liveUser;
229
+ var liveToken;
230
+ beforeAll(function () { return __awaiter(void 0, void 0, void 0, function () {
231
+ var _a;
175
232
  return __generator(this, function (_b) {
176
233
  switch (_b.label) {
177
- case 0: return [4 /*yield*/, passwordsByEmail()];
234
+ case 0: return [4 /*yield*/, armResetToken('reset-shapes@test.local')];
178
235
  case 1:
179
- before = _b.sent();
180
- return [4 /*yield*/, invokeExecute(__assign(__assign({}, tokenField), { newPassword: 'hijacked' }))];
236
+ (_a = _b.sent(), liveUser = _a.user, liveToken = _a.token);
237
+ return [2 /*return*/];
238
+ }
239
+ });
240
+ }); });
241
+ it.each(MalformedResetTokens_1.MalformedResetTokens.CASES)('%s: 400, nothing in the table changes, and nothing reaches the database', function (_label, tokenField) { return __awaiter(void 0, void 0, void 0, function () {
242
+ var before, _a, outcome, traffic, _b;
243
+ return __generator(this, function (_c) {
244
+ switch (_c.label) {
245
+ case 0: return [4 /*yield*/, resetStateByEmail()];
246
+ case 1:
247
+ before = _c.sent();
248
+ return [4 /*yield*/, DbTraffic_1.DbTraffic.during(testEnv.spannerDriver, function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
249
+ switch (_a.label) {
250
+ case 0: return [4 /*yield*/, invokeExecute(__assign(__assign({}, tokenField(liveToken)), { newPassword: 'hijacked' }))];
251
+ case 1: return [2 /*return*/, _a.sent()];
252
+ }
253
+ }); }); })];
181
254
  case 2:
182
- outcome = _b.sent();
255
+ _a = _c.sent(), outcome = _a.result, traffic = _a.traffic;
183
256
  expect(outcome.status).toBe(400);
184
257
  expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
185
- _a = expect;
186
- return [4 /*yield*/, passwordsByEmail()];
258
+ expect(traffic).toEqual(DbTraffic_1.DbTraffic.NONE);
259
+ _b = expect;
260
+ return [4 /*yield*/, resetStateByEmail()];
261
+ case 3:
262
+ _b.apply(void 0, [_c.sent()]).toEqual(before);
263
+ return [2 /*return*/];
264
+ }
265
+ });
266
+ }); });
267
+ it('after every refusal the live token still resets the password', function () { return __awaiter(void 0, void 0, void 0, function () {
268
+ var outcome, row;
269
+ return __generator(this, function (_a) {
270
+ switch (_a.label) {
271
+ case 0: return [4 /*yield*/, invokeExecute({ token: liveToken, newPassword: 'still live' })];
272
+ case 1:
273
+ outcome = _a.sent();
274
+ expect(outcome.status).toBe(200);
275
+ return [4 /*yield*/, userRow(liveUser.id)];
276
+ case 2:
277
+ row = _a.sent();
278
+ return [4 /*yield*/, expect(new PasswordHasher_1.PasswordHasher().verify(row.password, 'still live')).resolves.toBe(true)];
187
279
  case 3:
188
- _a.apply(void 0, [_b.sent()]).toEqual(before);
280
+ _a.sent();
189
281
  return [2 /*return*/];
190
282
  }
191
283
  });
192
284
  }); });
193
285
  });
286
+ it.each([
287
+ ['no body', 'reset-no-body@test.local', {}],
288
+ ['a null body', 'reset-null-body@test.local', { body: null }],
289
+ ])('a request with %s: 400, and nothing changes', function (_label, email, request) { return __awaiter(void 0, void 0, void 0, function () {
290
+ var _a, user, token, before, outcome, _b, _c;
291
+ return __generator(this, function (_d) {
292
+ switch (_d.label) {
293
+ case 0: return [4 /*yield*/, armResetToken(email)];
294
+ case 1:
295
+ _a = _d.sent(), user = _a.user, token = _a.token;
296
+ return [4 /*yield*/, resetStateByEmail()];
297
+ case 2:
298
+ before = _d.sent();
299
+ return [4 /*yield*/, invokeExecuteRequest(request)];
300
+ case 3:
301
+ outcome = _d.sent();
302
+ expect(outcome.status).toBe(400);
303
+ _b = expect;
304
+ return [4 /*yield*/, resetStateByEmail()];
305
+ case 4:
306
+ _b.apply(void 0, [_d.sent()]).toEqual(before);
307
+ _c = expect;
308
+ return [4 /*yield*/, userRow(user.id)];
309
+ case 5:
310
+ _c.apply(void 0, [(_d.sent()).passwordResetToken]).toBe(sha256Hex(token));
311
+ return [2 /*return*/];
312
+ }
313
+ });
314
+ }); });
194
315
  it('a live token resets the password once: the row verifies it, the token clears, a re-presentation is refused', function () { return __awaiter(void 0, void 0, void 0, function () {
195
- var token, user, first, afterFirst, second, _a;
196
- return __generator(this, function (_b) {
197
- switch (_b.label) {
198
- case 0:
199
- token = mintToken();
200
- return [4 /*yield*/, armResetToken('reset-live@test.local', token, (0, moment_1.default)().add(1, 'hour'))];
316
+ var _a, user, token, first, afterFirst, second, _b;
317
+ return __generator(this, function (_c) {
318
+ switch (_c.label) {
319
+ case 0: return [4 /*yield*/, armResetToken('reset-live@test.local')];
201
320
  case 1:
202
- user = _b.sent();
321
+ _a = _c.sent(), user = _a.user, token = _a.token;
203
322
  return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'first new password' })];
204
323
  case 2:
205
- first = _b.sent();
324
+ first = _c.sent();
206
325
  expect(first.status).toBe(200);
207
326
  expect(first.body).toEqual({ message: 'Password has been successfully reset' });
208
327
  return [4 /*yield*/, userRow(user.id)];
209
328
  case 3:
210
- afterFirst = _b.sent();
329
+ afterFirst = _c.sent();
211
330
  return [4 /*yield*/, expect(new PasswordHasher_1.PasswordHasher().verify(afterFirst.password, 'first new password')).resolves.toBe(true)];
212
331
  case 4:
213
- _b.sent();
332
+ _c.sent();
214
333
  expect(afterFirst.passwordResetToken).toBeNull();
215
334
  expect(afterFirst.passwordResetTokenExpiration).toBeNull();
216
335
  return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'second new password' })];
217
336
  case 5:
218
- second = _b.sent();
337
+ second = _c.sent();
219
338
  expect(second.status).toBe(400);
220
- _a = expect;
339
+ _b = expect;
221
340
  return [4 /*yield*/, userRow(user.id)];
222
341
  case 6:
223
- _a.apply(void 0, [(_b.sent()).password]).toBe(afterFirst.password);
342
+ _b.apply(void 0, [(_c.sent()).password]).toBe(afterFirst.password);
224
343
  return [2 /*return*/];
225
344
  }
226
345
  });
227
346
  }); });
228
- it('an expired token is refused and changes nothing', function () { return __awaiter(void 0, void 0, void 0, function () {
229
- var token, user, before, outcome, row;
230
- return __generator(this, function (_a) {
231
- switch (_a.label) {
232
- case 0:
233
- token = mintToken();
234
- return [4 /*yield*/, armResetToken('reset-expired@test.local', token, (0, moment_1.default)().subtract(1, 'minute'))];
347
+ it("two simultaneous presentations of one token: exactly one 200, and the row verifies only the winner's password", function () { return __awaiter(void 0, void 0, void 0, function () {
348
+ var _a, user, token, candidates, hashPassword, release, bothHashed, hashed, hash, outcomes, winner, row, _b;
349
+ return __generator(this, function (_c) {
350
+ switch (_c.label) {
351
+ case 0: return [4 /*yield*/, armResetToken('reset-race@test.local')];
352
+ case 1:
353
+ _a = _c.sent(), user = _a.user, token = _a.token;
354
+ candidates = ['racer one password', 'racer two password'];
355
+ hashPassword = PasswordHasher_1.PasswordHasher.prototype.hash;
356
+ release = function () { return undefined; };
357
+ bothHashed = new Promise(function (resolve) { return (release = resolve); });
358
+ hashed = 0;
359
+ hash = jest.spyOn(PasswordHasher_1.PasswordHasher.prototype, 'hash').mockImplementation(function (password) {
360
+ return __awaiter(this, void 0, void 0, function () {
361
+ var hashedPassword;
362
+ return __generator(this, function (_a) {
363
+ switch (_a.label) {
364
+ case 0: return [4 /*yield*/, hashPassword.call(this, password)];
365
+ case 1:
366
+ hashedPassword = _a.sent();
367
+ if (++hashed === candidates.length) {
368
+ release();
369
+ }
370
+ return [4 /*yield*/, bothHashed];
371
+ case 2:
372
+ _a.sent();
373
+ return [2 /*return*/, hashedPassword];
374
+ }
375
+ });
376
+ });
377
+ });
378
+ _c.label = 2;
379
+ case 2:
380
+ _c.trys.push([2, , 4, 5]);
381
+ return [4 /*yield*/, Promise.all(candidates.map(function (newPassword) { return invokeExecute({ token: token, newPassword: newPassword }); }))];
382
+ case 3:
383
+ outcomes = _c.sent();
384
+ return [3 /*break*/, 5];
385
+ case 4:
386
+ hash.mockRestore();
387
+ return [7 /*endfinally*/];
388
+ case 5:
389
+ expect(outcomes.map(function (outcome) { return outcome.status; }).sort()).toEqual([200, 400]);
390
+ winner = candidates[outcomes.findIndex(function (outcome) { return outcome.status === 200; })];
391
+ return [4 /*yield*/, userRow(user.id)];
392
+ case 6:
393
+ row = _c.sent();
394
+ _b = expect;
395
+ return [4 /*yield*/, verifiedPasswords(row.password, candidates)];
396
+ case 7:
397
+ _b.apply(void 0, [_c.sent()]).toEqual([winner]);
398
+ expect(row.passwordResetToken).toBeNull();
399
+ expect(row.passwordResetTokenExpiration).toBeNull();
400
+ return [2 /*return*/];
401
+ }
402
+ });
403
+ }); });
404
+ it('the row stores the SHA-256 digest of the token, never the token — and the digest presented as a token is refused', function () { return __awaiter(void 0, void 0, void 0, function () {
405
+ var _a, user, token, armed, _b, outcome, traffic, row;
406
+ return __generator(this, function (_c) {
407
+ switch (_c.label) {
408
+ case 0: return [4 /*yield*/, armResetToken('reset-digest@test.local')];
235
409
  case 1:
236
- user = _a.sent();
410
+ _a = _c.sent(), user = _a.user, token = _a.token;
237
411
  return [4 /*yield*/, userRow(user.id)];
238
412
  case 2:
239
- before = (_a.sent()).password;
240
- return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'hijacked' })];
413
+ armed = _c.sent();
414
+ expect(armed.passwordResetToken).toBe(sha256Hex(token));
415
+ expect(JSON.stringify(armed)).not.toContain(token);
416
+ return [4 /*yield*/, DbTraffic_1.DbTraffic.during(testEnv.spannerDriver, function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
417
+ switch (_a.label) {
418
+ case 0: return [4 /*yield*/, invokeExecute({ token: armed.passwordResetToken, newPassword: 'hijacked' })];
419
+ case 1: return [2 /*return*/, _a.sent()];
420
+ }
421
+ }); }); })];
241
422
  case 3:
242
- outcome = _a.sent();
423
+ _b = _c.sent(), outcome = _b.result, traffic = _b.traffic;
243
424
  expect(outcome.status).toBe(400);
244
- expect(outcome.body).toEqual({ error: 'Reset token has expired' });
425
+ expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
426
+ expect(traffic.lookups).toEqual([{ passwordResetToken: sha256Hex(sha256Hex(token)) }]);
427
+ expect(traffic.statements).toBe(1);
428
+ expect(traffic.writes).toBe(0);
245
429
  return [4 /*yield*/, userRow(user.id)];
246
430
  case 4:
247
- row = _a.sent();
248
- expect(row.password).toBe(before);
249
- expect(row.passwordResetToken).toBe(token);
431
+ row = _c.sent();
432
+ expect(row.password).toBe(armed.password);
433
+ expect(row.passwordResetToken).toBe(sha256Hex(token));
250
434
  return [2 /*return*/];
251
435
  }
252
436
  });
253
437
  }); });
254
- it('a token whose row carries no expiry is refused — never live by omission', function () { return __awaiter(void 0, void 0, void 0, function () {
255
- var token, user, before, outcome, _a;
438
+ it('a token an earlier release stored in clear is refused — it fails for the rest of its hour, no migration', function () { return __awaiter(void 0, void 0, void 0, function () {
439
+ var clearToken, user, before, outcome, _a;
256
440
  return __generator(this, function (_b) {
257
441
  switch (_b.label) {
258
442
  case 0:
259
- token = mintToken();
260
- return [4 /*yield*/, armResetToken('reset-no-expiry@test.local', token, null)];
443
+ clearToken = unissuedToken();
444
+ return [4 /*yield*/, armRawTokenColumn('reset-stored-in-clear@test.local', clearToken)];
261
445
  case 1:
262
446
  user = _b.sent();
263
447
  return [4 /*yield*/, userRow(user.id)];
264
448
  case 2:
265
449
  before = (_b.sent()).password;
266
- return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'hijacked' })];
450
+ return [4 /*yield*/, invokeExecute({ token: clearToken, newPassword: 'hijacked' })];
267
451
  case 3:
268
452
  outcome = _b.sent();
269
453
  expect(outcome.status).toBe(400);
454
+ expect(outcome.body).toEqual({ error: 'Invalid or expired reset token' });
270
455
  _a = expect;
271
456
  return [4 /*yield*/, userRow(user.id)];
272
457
  case 4:
@@ -275,80 +460,211 @@ describe('executePasswordReset route', function () {
275
460
  }
276
461
  });
277
462
  }); });
278
- it('a blank new password is refused without consuming the token', function () { return __awaiter(void 0, void 0, void 0, function () {
279
- var token, user, before, _i, _a, newPassword, outcome, row;
463
+ it('an expired token is refused and changes nothing', function () { return __awaiter(void 0, void 0, void 0, function () {
464
+ var _a, user, token, before, outcome, row;
280
465
  return __generator(this, function (_b) {
281
466
  switch (_b.label) {
282
- case 0:
283
- token = mintToken();
284
- return [4 /*yield*/, armResetToken('reset-blank@test.local', token, (0, moment_1.default)().add(1, 'hour'))];
467
+ case 0: return [4 /*yield*/, armResetToken('reset-expired@test.local', (0, moment_1.default)().subtract(1, 'minute'))];
285
468
  case 1:
286
- user = _b.sent();
469
+ _a = _b.sent(), user = _a.user, token = _a.token;
287
470
  return [4 /*yield*/, userRow(user.id)];
288
471
  case 2:
289
472
  before = (_b.sent()).password;
290
- _i = 0, _a = ['', undefined, 42];
291
- _b.label = 3;
473
+ return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'hijacked' })];
474
+ case 3:
475
+ outcome = _b.sent();
476
+ expect(outcome.status).toBe(400);
477
+ expect(outcome.body).toEqual({ error: 'Reset token has expired' });
478
+ return [4 /*yield*/, userRow(user.id)];
479
+ case 4:
480
+ row = _b.sent();
481
+ expect(row.password).toBe(before);
482
+ expect(row.passwordResetToken).toBe(sha256Hex(token));
483
+ return [2 /*return*/];
484
+ }
485
+ });
486
+ }); });
487
+ it('a token whose row carries no expiry is refused — never live by omission', function () { return __awaiter(void 0, void 0, void 0, function () {
488
+ var _a, user, token, before, outcome, _b;
489
+ return __generator(this, function (_c) {
490
+ switch (_c.label) {
491
+ case 0: return [4 /*yield*/, armResetToken('reset-no-expiry@test.local', null)];
492
+ case 1:
493
+ _a = _c.sent(), user = _a.user, token = _a.token;
494
+ return [4 /*yield*/, userRow(user.id)];
495
+ case 2:
496
+ before = (_c.sent()).password;
497
+ return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'hijacked' })];
498
+ case 3:
499
+ outcome = _c.sent();
500
+ expect(outcome.status).toBe(400);
501
+ _b = expect;
502
+ return [4 /*yield*/, userRow(user.id)];
503
+ case 4:
504
+ _b.apply(void 0, [(_c.sent()).password]).toBe(before);
505
+ return [2 /*return*/];
506
+ }
507
+ });
508
+ }); });
509
+ it('a blank new password is refused without consuming the token', function () { return __awaiter(void 0, void 0, void 0, function () {
510
+ var _a, user, token, before, _i, _b, newPassword, outcome, row;
511
+ return __generator(this, function (_c) {
512
+ switch (_c.label) {
513
+ case 0: return [4 /*yield*/, armResetToken('reset-blank@test.local')];
514
+ case 1:
515
+ _a = _c.sent(), user = _a.user, token = _a.token;
516
+ return [4 /*yield*/, userRow(user.id)];
517
+ case 2:
518
+ before = (_c.sent()).password;
519
+ _i = 0, _b = ['', undefined, 42];
520
+ _c.label = 3;
292
521
  case 3:
293
- if (!(_i < _a.length)) return [3 /*break*/, 6];
294
- newPassword = _a[_i];
522
+ if (!(_i < _b.length)) return [3 /*break*/, 6];
523
+ newPassword = _b[_i];
295
524
  return [4 /*yield*/, invokeExecute({ token: token, newPassword: newPassword })];
296
525
  case 4:
297
- outcome = _b.sent();
526
+ outcome = _c.sent();
298
527
  expect(outcome.status).toBe(400);
299
- _b.label = 5;
528
+ _c.label = 5;
300
529
  case 5:
301
530
  _i++;
302
531
  return [3 /*break*/, 3];
303
532
  case 6: return [4 /*yield*/, userRow(user.id)];
304
533
  case 7:
305
- row = _b.sent();
534
+ row = _c.sent();
306
535
  expect(row.password).toBe(before);
307
- expect(row.passwordResetToken).toBe(token);
536
+ expect(row.passwordResetToken).toBe(sha256Hex(token));
308
537
  return [2 /*return*/];
309
538
  }
310
539
  });
311
540
  }); });
312
- it('the presented token never reaches the log', function () { return __awaiter(void 0, void 0, void 0, function () {
313
- var token, info, _i, _a, entry;
541
+ it('no token, no leading characters of one and no full digest reach the log — every path through the route', function () { return __awaiter(void 0, void 0, void 0, function () {
542
+ var live, expired, unissued, log, _i, _a, token;
314
543
  return __generator(this, function (_b) {
315
544
  switch (_b.label) {
316
- case 0:
317
- token = mintToken();
318
- info = jest.spyOn(logger_1.Logger.prototype, 'info');
319
- _b.label = 1;
545
+ case 0: return [4 /*yield*/, armResetToken('reset-log-live@test.local')];
320
546
  case 1:
321
- _b.trys.push([1, , 3, 4]);
322
- return [4 /*yield*/, invokeExecute({ token: token, newPassword: 'hijacked' })];
547
+ live = (_b.sent()).token;
548
+ return [4 /*yield*/, armResetToken('reset-log-expired@test.local', (0, moment_1.default)().subtract(1, 'minute'))];
323
549
  case 2:
324
- _b.sent();
325
- expect(info).toHaveBeenCalled();
326
- for (_i = 0, _a = info.mock.calls; _i < _a.length; _i++) {
327
- entry = _a[_i][0];
328
- expect(JSON.stringify(entry)).not.toContain(token);
329
- }
330
- return [3 /*break*/, 4];
550
+ expired = (_b.sent()).token;
551
+ unissued = unissuedToken();
552
+ return [4 /*yield*/, LogCapture_1.LogCapture.during(function () { return __awaiter(void 0, void 0, void 0, function () {
553
+ return __generator(this, function (_a) {
554
+ switch (_a.label) {
555
+ case 0: return [4 /*yield*/, invokeExecute({ token: unissued, newPassword: 'hijacked' })];
556
+ case 1:
557
+ _a.sent();
558
+ return [4 /*yield*/, invokeExecute({ token: live.toUpperCase(), newPassword: 'hijacked' })];
559
+ case 2:
560
+ _a.sent();
561
+ return [4 /*yield*/, invokeExecute({ token: sha256Hex(live), newPassword: 'hijacked' })];
562
+ case 3:
563
+ _a.sent();
564
+ return [4 /*yield*/, invokeExecute({ token: expired, newPassword: 'hijacked' })];
565
+ case 4:
566
+ _a.sent();
567
+ return [4 /*yield*/, invokeExecute({ token: live, newPassword: 'a logged password' })];
568
+ case 5:
569
+ _a.sent();
570
+ return [4 /*yield*/, invokeExecute({ token: live, newPassword: 'a second logged password' })];
571
+ case 6:
572
+ _a.sent();
573
+ return [2 /*return*/];
574
+ }
575
+ });
576
+ }); })];
331
577
  case 3:
332
- info.mockRestore();
333
- return [7 /*endfinally*/];
334
- case 4: return [2 /*return*/];
578
+ log = _b.sent();
579
+ // The capture saw the route's lines: each path above logged.
580
+ expect(log.text).toContain('Invalid reset token used');
581
+ expect(log.text).toContain('Expired reset token used');
582
+ expect(log.text).toContain('Password successfully reset');
583
+ expect(log.lines.length).toBeGreaterThanOrEqual(6);
584
+ // The digest presented as a token is covered by `live`: its full digest is what was presented.
585
+ for (_i = 0, _a = [unissued, live, expired]; _i < _a.length; _i++) {
586
+ token = _a[_i];
587
+ log.expectFreeOf(token);
588
+ }
589
+ return [2 /*return*/];
335
590
  }
336
591
  });
337
592
  }); });
338
593
  describe('PasswordResetToken', function () {
339
594
  var internals = new PasswordResetToken_1.PasswordResetToken();
340
- it('mints 64 lowercase hex characters, fresh each time', function () {
341
- var token = mintToken();
342
- expect(token).toMatch(/^[0-9a-f]{64}$/);
343
- expect(mintToken()).not.toBe(token);
595
+ it('mints 64 lowercase hex characters, fresh each time, and stores the digest with an expiry an hour out', function () { return __awaiter(void 0, void 0, void 0, function () {
596
+ var user, first, afterFirst, second, afterSecond, minutesOut;
597
+ return __generator(this, function (_a) {
598
+ switch (_a.label) {
599
+ case 0: return [4 /*yield*/, testEnv.createUser({ name: 'Reset User', email: 'reset-mint@test.local' })];
600
+ case 1:
601
+ user = _a.sent();
602
+ return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().mint(user)];
603
+ case 2:
604
+ first = _a.sent();
605
+ return [4 /*yield*/, userRow(user.id)];
606
+ case 3:
607
+ afterFirst = _a.sent();
608
+ return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().mint(user)];
609
+ case 4:
610
+ second = _a.sent();
611
+ return [4 /*yield*/, userRow(user.id)];
612
+ case 5:
613
+ afterSecond = _a.sent();
614
+ expect(first).toMatch(/^[0-9a-f]{64}$/);
615
+ expect(second).toMatch(/^[0-9a-f]{64}$/);
616
+ expect(second).not.toBe(first);
617
+ expect(afterFirst.passwordResetToken).toBe(sha256Hex(first));
618
+ expect(afterSecond.passwordResetToken).toBe(sha256Hex(second));
619
+ minutesOut = (0, moment_1.default)(afterSecond.passwordResetTokenExpiration).diff((0, moment_1.default)(), 'minutes', true);
620
+ expect(minutesOut).toBeGreaterThan(59);
621
+ expect(minutesOut).toBeLessThanOrEqual(60);
622
+ return [2 /*return*/];
623
+ }
624
+ });
625
+ }); });
626
+ it.each(MalformedResetTokens_1.MalformedResetTokens.CASES)('resolve refuses %s as malformed without touching the database, and gives it no log reference', function (_label, tokenField) { return __awaiter(void 0, void 0, void 0, function () {
627
+ var presented, _a, resolution, traffic;
628
+ return __generator(this, function (_b) {
629
+ switch (_b.label) {
630
+ case 0:
631
+ presented = tokenField(unissuedToken()).token;
632
+ return [4 /*yield*/, DbTraffic_1.DbTraffic.during(testEnv.spannerDriver, function () { return __awaiter(void 0, void 0, void 0, function () { return __generator(this, function (_a) {
633
+ switch (_a.label) {
634
+ case 0: return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().resolve(presented)];
635
+ case 1: return [2 /*return*/, _a.sent()];
636
+ }
637
+ }); }); })];
638
+ case 1:
639
+ _a = _b.sent(), resolution = _a.result, traffic = _a.traffic;
640
+ expect(resolution).toEqual({ status: 'malformed' });
641
+ expect(traffic).toEqual(DbTraffic_1.DbTraffic.NONE);
642
+ expect(new PasswordResetToken_1.PasswordResetToken().fingerprint(presented)).toBeUndefined();
643
+ return [2 /*return*/];
644
+ }
645
+ });
646
+ }); });
647
+ 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', function () {
648
+ var token = unissuedToken();
649
+ var another = unissuedToken();
650
+ var reference = new PasswordResetToken_1.PasswordResetToken().fingerprint(token);
651
+ expect(reference).toBe(sha256Hex(token).slice(0, 12));
652
+ expect(reference).not.toBe(token.slice(0, 12));
653
+ expect(new PasswordResetToken_1.PasswordResetToken().fingerprint(token)).toBe(reference);
654
+ expect(new PasswordResetToken_1.PasswordResetToken().fingerprint(another)).not.toBe(reference);
655
+ });
656
+ it('the digest is the SHA-256 of the token, hex-encoded', function () {
657
+ var token = unissuedToken();
658
+ expect(internals.digest(token)).toBe(sha256Hex(token));
659
+ expect(internals.digest(token)).not.toBe(token);
344
660
  });
345
- it('a stored token matches only the same string — never an absent or emptied one', function () {
346
- var token = mintToken();
347
- expect(internals.matches(token, token)).toBe(true);
348
- expect(internals.matches(mintToken(), token)).toBe(false);
349
- expect(internals.matches(null, token)).toBe(false);
350
- expect(internals.matches(undefined, token)).toBe(false);
351
- expect(internals.matches('', token)).toBe(false);
661
+ it('a stored digest matches only the same digest — never an absent or emptied one', function () {
662
+ var digest = sha256Hex('one token');
663
+ expect(internals.matches(digest, digest)).toBe(true);
664
+ expect(internals.matches(sha256Hex('another token'), digest)).toBe(false);
665
+ expect(internals.matches(null, digest)).toBe(false);
666
+ expect(internals.matches(undefined, digest)).toBe(false);
667
+ expect(internals.matches('', digest)).toBe(false);
352
668
  });
353
669
  it('an expiry is live only as a valid timestamp in the future', function () {
354
670
  expect(internals.isLive((0, moment_1.default)().add(1, 'minute'))).toBe(true);
@@ -357,34 +673,107 @@ describe('executePasswordReset route', function () {
357
673
  expect(internals.isLive(undefined)).toBe(false);
358
674
  expect(internals.isLive(moment_1.default.invalid())).toBe(false);
359
675
  });
360
- it('redeem writes only while the row still carries the token', function () { return __awaiter(void 0, void 0, void 0, function () {
361
- var live, user, before, _a, row;
676
+ it("redeem writes only while the row still carries the token's digest", function () { return __awaiter(void 0, void 0, void 0, function () {
677
+ var _a, user, live, before, _b, row;
678
+ return __generator(this, function (_c) {
679
+ switch (_c.label) {
680
+ case 0: return [4 /*yield*/, armResetToken('reset-redeem@test.local')];
681
+ case 1:
682
+ _a = _c.sent(), user = _a.user, live = _a.token;
683
+ return [4 /*yield*/, userRow(user.id)];
684
+ case 2:
685
+ before = (_c.sent()).password;
686
+ return [4 /*yield*/, expect(new PasswordResetToken_1.PasswordResetToken().redeem(user, unissuedToken(), 'other-hash')).resolves.toBe(false)];
687
+ case 3:
688
+ _c.sent();
689
+ return [4 /*yield*/, expect(new PasswordResetToken_1.PasswordResetToken().redeem(user, sha256Hex(live), 'other-hash')).resolves.toBe(false)];
690
+ case 4:
691
+ _c.sent();
692
+ _b = expect;
693
+ return [4 /*yield*/, userRow(user.id)];
694
+ case 5:
695
+ _b.apply(void 0, [(_c.sent()).password]).toBe(before);
696
+ return [4 /*yield*/, expect(new PasswordResetToken_1.PasswordResetToken().redeem(user, live, 'new-hash')).resolves.toBe(true)];
697
+ case 6:
698
+ _c.sent();
699
+ return [4 /*yield*/, userRow(user.id)];
700
+ case 7:
701
+ row = _c.sent();
702
+ expect(row.password).toBe('new-hash');
703
+ expect(row.passwordResetToken).toBeNull();
704
+ expect(row.passwordResetTokenExpiration).toBeNull();
705
+ return [2 /*return*/];
706
+ }
707
+ });
708
+ }); });
709
+ it('concurrent redeems of one token: exactly one resolves true, and the row holds the winner', function () { return __awaiter(void 0, void 0, void 0, function () {
710
+ var _a, user, token, hashes, results, row;
362
711
  return __generator(this, function (_b) {
363
712
  switch (_b.label) {
364
- case 0:
365
- live = mintToken();
366
- return [4 /*yield*/, armResetToken('reset-redeem@test.local', live, (0, moment_1.default)().add(1, 'hour'))];
713
+ case 0: return [4 /*yield*/, armResetToken('reset-redeem-race@test.local')];
367
714
  case 1:
368
- user = _b.sent();
715
+ _a = _b.sent(), user = _a.user, token = _a.token;
716
+ hashes = ['hash-1', 'hash-2', 'hash-3', 'hash-4', 'hash-5', 'hash-6', 'hash-7', 'hash-8'];
717
+ return [4 /*yield*/, Promise.all(hashes.map(function (hash) { return new PasswordResetToken_1.PasswordResetToken().redeem(user, token, hash); }))];
718
+ case 2:
719
+ results = _b.sent();
720
+ expect(results.filter(function (redeemed) { return redeemed; })).toHaveLength(1);
369
721
  return [4 /*yield*/, userRow(user.id)];
722
+ case 3:
723
+ row = _b.sent();
724
+ expect(row.password).toBe(hashes[results.indexOf(true)]);
725
+ expect(row.passwordResetToken).toBeNull();
726
+ return [2 /*return*/];
727
+ }
728
+ });
729
+ }); });
730
+ it('revoke clears the row only while it still carries the token — a newer token is left alone', function () { return __awaiter(void 0, void 0, void 0, function () {
731
+ var _a, user, withdrawn, newer, _b, row;
732
+ return __generator(this, function (_c) {
733
+ switch (_c.label) {
734
+ case 0: return [4 /*yield*/, armResetToken('reset-revoke@test.local')];
735
+ case 1:
736
+ _a = _c.sent(), user = _a.user, withdrawn = _a.token;
737
+ return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().mint(user)];
370
738
  case 2:
371
- before = (_b.sent()).password;
372
- return [4 /*yield*/, expect(new PasswordResetToken_1.PasswordResetToken().redeem(user, mintToken(), 'other-hash')).resolves.toBe(false)];
739
+ newer = _c.sent();
740
+ return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().revoke(user, withdrawn)];
373
741
  case 3:
374
- _b.sent();
375
- _a = expect;
742
+ _c.sent();
743
+ _b = expect;
376
744
  return [4 /*yield*/, userRow(user.id)];
377
745
  case 4:
378
- _a.apply(void 0, [(_b.sent()).password]).toBe(before);
379
- return [4 /*yield*/, expect(new PasswordResetToken_1.PasswordResetToken().redeem(user, live, 'new-hash')).resolves.toBe(true)];
746
+ _b.apply(void 0, [(_c.sent()).passwordResetToken]).toBe(sha256Hex(newer));
747
+ return [4 /*yield*/, new PasswordResetToken_1.PasswordResetToken().revoke(user, newer)];
380
748
  case 5:
381
- _b.sent();
749
+ _c.sent();
382
750
  return [4 /*yield*/, userRow(user.id)];
383
751
  case 6:
384
- row = _b.sent();
385
- expect(row.password).toBe('new-hash');
752
+ row = _c.sent();
386
753
  expect(row.passwordResetToken).toBeNull();
387
754
  expect(row.passwordResetTokenExpiration).toBeNull();
755
+ expect(row.password).toBe('test');
756
+ return [2 /*return*/];
757
+ }
758
+ });
759
+ }); });
760
+ it('mintedAt is the moment the outstanding token was minted — undefined when the row carries none', function () { return __awaiter(void 0, void 0, void 0, function () {
761
+ var idle, user, mintedAt, _a, _b;
762
+ return __generator(this, function (_c) {
763
+ switch (_c.label) {
764
+ case 0: return [4 /*yield*/, testEnv.createUser({ name: 'Reset User', email: 'reset-minted-at-idle@test.local' })];
765
+ case 1:
766
+ idle = _c.sent();
767
+ return [4 /*yield*/, armResetToken('reset-minted-at@test.local')];
768
+ case 2:
769
+ user = (_c.sent()).user;
770
+ expect(new PasswordResetToken_1.PasswordResetToken().mintedAt(idle)).toBeUndefined();
771
+ _b = (_a = new PasswordResetToken_1.PasswordResetToken()).mintedAt;
772
+ return [4 /*yield*/, userRow(user.id)];
773
+ case 3:
774
+ mintedAt = _b.apply(_a, [_c.sent()]);
775
+ expect(mintedAt).toBeDefined();
776
+ expect(Math.abs((0, moment_1.default)().diff(mintedAt, 'seconds'))).toBeLessThan(30);
388
777
  return [2 /*return*/];
389
778
  }
390
779
  });