@proteinjs/user-server 1.22.1 → 1.22.2

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 (84) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/generated/index.js +1 -1
  3. package/dist/generated/index.js.map +1 -1
  4. package/dist/index.d.ts +3 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +9 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/src/authentication/PasswordHasher.d.ts +9 -0
  9. package/dist/src/authentication/PasswordHasher.d.ts.map +1 -1
  10. package/dist/src/authentication/PasswordHasher.js +25 -0
  11. package/dist/src/authentication/PasswordHasher.js.map +1 -1
  12. package/dist/src/authentication/authenticate.d.ts +15 -0
  13. package/dist/src/authentication/authenticate.d.ts.map +1 -1
  14. package/dist/src/authentication/authenticate.js +55 -20
  15. package/dist/src/authentication/authenticate.js.map +1 -1
  16. package/dist/src/routes/executePasswordReset.d.ts +4 -0
  17. package/dist/src/routes/executePasswordReset.d.ts.map +1 -1
  18. package/dist/src/routes/executePasswordReset.js +7 -0
  19. package/dist/src/routes/executePasswordReset.js.map +1 -1
  20. package/dist/src/routes/initiatePasswordReset.d.ts +7 -0
  21. package/dist/src/routes/initiatePasswordReset.d.ts.map +1 -1
  22. package/dist/src/routes/initiatePasswordReset.js +28 -10
  23. package/dist/src/routes/initiatePasswordReset.js.map +1 -1
  24. package/dist/src/routes/login.d.ts +8 -0
  25. package/dist/src/routes/login.d.ts.map +1 -1
  26. package/dist/src/routes/login.js +63 -22
  27. package/dist/src/routes/login.js.map +1 -1
  28. package/dist/src/throttle/ClientAddress.d.ts +35 -0
  29. package/dist/src/throttle/ClientAddress.d.ts.map +1 -0
  30. package/dist/src/throttle/ClientAddress.js +48 -0
  31. package/dist/src/throttle/ClientAddress.js.map +1 -0
  32. package/dist/src/throttle/PasswordResetThrottle.d.ts +39 -0
  33. package/dist/src/throttle/PasswordResetThrottle.d.ts.map +1 -0
  34. package/dist/src/throttle/PasswordResetThrottle.js +59 -0
  35. package/dist/src/throttle/PasswordResetThrottle.js.map +1 -0
  36. package/dist/src/throttle/RequestDigests.d.ts +42 -0
  37. package/dist/src/throttle/RequestDigests.d.ts.map +1 -0
  38. package/dist/src/throttle/RequestDigests.js +96 -0
  39. package/dist/src/throttle/RequestDigests.js.map +1 -0
  40. package/dist/src/throttle/SignInThrottle.d.ts +67 -0
  41. package/dist/src/throttle/SignInThrottle.d.ts.map +1 -0
  42. package/dist/src/throttle/SignInThrottle.js +91 -0
  43. package/dist/src/throttle/SignInThrottle.js.map +1 -0
  44. package/dist/src/throttle/SlidingWindow.d.ts +51 -0
  45. package/dist/src/throttle/SlidingWindow.d.ts.map +1 -0
  46. package/dist/src/throttle/SlidingWindow.js +85 -0
  47. package/dist/src/throttle/SlidingWindow.js.map +1 -0
  48. package/dist/test/ClientAddress.test.d.ts +2 -0
  49. package/dist/test/ClientAddress.test.d.ts.map +1 -0
  50. package/dist/test/ClientAddress.test.js +51 -0
  51. package/dist/test/ClientAddress.test.js.map +1 -0
  52. package/dist/test/InitiatePasswordReset.test.js +373 -5
  53. package/dist/test/InitiatePasswordReset.test.js.map +1 -1
  54. package/dist/test/LoginThrottle.integration.test.d.ts +2 -0
  55. package/dist/test/LoginThrottle.integration.test.d.ts.map +1 -0
  56. package/dist/test/LoginThrottle.integration.test.js +616 -0
  57. package/dist/test/LoginThrottle.integration.test.js.map +1 -0
  58. package/dist/test/RequestDigests.test.d.ts +2 -0
  59. package/dist/test/RequestDigests.test.d.ts.map +1 -0
  60. package/dist/test/RequestDigests.test.js +73 -0
  61. package/dist/test/RequestDigests.test.js.map +1 -0
  62. package/dist/test/SlidingWindow.test.d.ts +2 -0
  63. package/dist/test/SlidingWindow.test.d.ts.map +1 -0
  64. package/dist/test/SlidingWindow.test.js +72 -0
  65. package/dist/test/SlidingWindow.test.js.map +1 -0
  66. package/generated/index.ts +1 -1
  67. package/index.ts +5 -0
  68. package/package.json +2 -2
  69. package/src/authentication/PasswordHasher.ts +18 -1
  70. package/src/authentication/authenticate.ts +36 -9
  71. package/src/routes/executePasswordReset.ts +7 -0
  72. package/src/routes/initiatePasswordReset.ts +29 -11
  73. package/src/routes/login.ts +39 -8
  74. package/src/throttle/ClientAddress.ts +47 -0
  75. package/src/throttle/PasswordResetThrottle.ts +62 -0
  76. package/src/throttle/RequestDigests.ts +97 -0
  77. package/src/throttle/SignInThrottle.ts +98 -0
  78. package/src/throttle/SlidingWindow.ts +90 -0
  79. package/test/ClientAddress.test.ts +54 -0
  80. package/test/InitiatePasswordReset.test.ts +212 -6
  81. package/test/LoginThrottle.integration.test.ts +339 -0
  82. package/test/RequestDigests.test.ts +81 -0
  83. package/test/SlidingWindow.test.ts +85 -0
  84. package/test/setup.js +2 -0
@@ -0,0 +1,81 @@
1
+ import { createHash } from 'crypto';
2
+ import { RequestDigests } from '../src/throttle/RequestDigests';
3
+
4
+ /**
5
+ * The two keyed digests the throttled doors key their windows on and write on their log lines:
6
+ * the account digest (a keyed hash of the lowercased address) and the coarse IP hash. Both are
7
+ * stable for one key (so an operator can say "one account, one device" across lines, pods and
8
+ * restarts) and useless without it (never the address, never a plain hash a list of addresses
9
+ * could reverse).
10
+ */
11
+ describe('RequestDigests', () => {
12
+ const digests = new RequestDigests({ secret: 'test-session-secret' });
13
+
14
+ it('the account digest is stable for one address whatever its case or surrounding spaces', () => {
15
+ const digest = digests.account('Ada.Lovelace@Example.com');
16
+
17
+ expect(digest).toMatch(/^[0-9a-f]{16}$/);
18
+ expect(digests.account('ada.lovelace@example.com')).toBe(digest);
19
+ expect(digests.account(' ADA.LOVELACE@EXAMPLE.COM ')).toBe(digest);
20
+ expect(digests.account('ada.lovelace@example.org')).not.toBe(digest);
21
+ });
22
+
23
+ it('the account digest is keyed: another key gives another digest, and it is no plain hash of the address', () => {
24
+ const address = 'ada.lovelace@example.com';
25
+ const digest = digests.account(address);
26
+
27
+ expect(new RequestDigests({ secret: 'another-secret' }).account(address)).not.toBe(digest);
28
+ expect(createHash('sha256').update(address).digest('hex')).not.toContain(digest);
29
+ expect(digest).not.toContain('ada');
30
+ });
31
+
32
+ it('the account digest and the coarse IP hash are separate: one value never reads as the other', () => {
33
+ expect(digests.account('203.0.113.7')).not.toBe(digests.coarseIp('203.0.113.7'));
34
+ });
35
+
36
+ it('the coarse IP hash is stable for one IPv4 address and differs for the next', () => {
37
+ const hash = digests.coarseIp('203.0.113.7');
38
+
39
+ expect(hash).toMatch(/^[0-9a-f]{16}$/);
40
+ expect(digests.coarseIp('203.0.113.7')).toBe(hash);
41
+ expect(digests.coarseIp('::ffff:203.0.113.7')).toBe(hash);
42
+ expect(digests.coarseIp('203.0.113.8')).not.toBe(hash);
43
+ });
44
+
45
+ it('IPv6 is coarsened to its /64 — one device rotates freely inside its own /64', () => {
46
+ const hash = digests.coarseIp('2001:db8:1:2:aaaa:bbbb:cccc:dddd');
47
+
48
+ expect(digests.coarseIp('2001:db8:1:2::1')).toBe(hash);
49
+ expect(digests.coarseIp('2001:0db8:0001:0002:ffff:0:0:1')).toBe(hash);
50
+ expect(digests.coarseIp('2001:db8:1:3::1')).not.toBe(hash);
51
+ });
52
+
53
+ it('with no key configured the digests refuse to run — never a plain hash, never a key of their own', () => {
54
+ const original = process.env.SESSION_SECRET;
55
+ delete process.env.SESSION_SECRET;
56
+ try {
57
+ expect(() => new RequestDigests().account('ada.lovelace@example.com')).toThrow(/SESSION_SECRET/);
58
+ expect(() => new RequestDigests({ secret: '' }).coarseIp('203.0.113.7')).toThrow(/SESSION_SECRET/);
59
+ } finally {
60
+ if (original !== undefined) {
61
+ process.env.SESSION_SECRET = original;
62
+ }
63
+ }
64
+ });
65
+
66
+ it('reads the key the deployment configures (SESSION_SECRET) when none is passed', () => {
67
+ const original = process.env.SESSION_SECRET;
68
+ process.env.SESSION_SECRET = 'test-session-secret';
69
+ try {
70
+ expect(new RequestDigests().account('ada.lovelace@example.com')).toBe(
71
+ digests.account('ada.lovelace@example.com')
72
+ );
73
+ } finally {
74
+ if (original === undefined) {
75
+ delete process.env.SESSION_SECRET;
76
+ } else {
77
+ process.env.SESSION_SECRET = original;
78
+ }
79
+ }
80
+ });
81
+ });
@@ -0,0 +1,85 @@
1
+ import { SlidingWindow } from '../src/throttle/SlidingWindow';
2
+
3
+ /**
4
+ * The per-key sliding window every throttled door shares (the sign-in and reset doors here, the
5
+ * invite-request door in the consuming app): `hit` records and answers whether the key is over,
6
+ * `isOver` only reads, `record` only counts, `clear` forgets. Attempts refused as over the window
7
+ * are not counted, so a key drains exactly one window after its last counted attempt.
8
+ */
9
+ describe('SlidingWindow', () => {
10
+ const MINUTE = 60 * 1000;
11
+ let now = 1_700_000_000_000;
12
+ const clock = () => now;
13
+
14
+ it('admits up to the limit inside the window and refuses the next', () => {
15
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 3, now: clock });
16
+
17
+ expect([window.hit('a'), window.hit('a'), window.hit('a')]).toEqual([false, false, false]);
18
+ expect(window.hit('a')).toBe(true);
19
+ expect(window.isOver('a')).toBe(true);
20
+ });
21
+
22
+ it('keeps each key in its own window', () => {
23
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 2, now: clock });
24
+ window.hit('a');
25
+ window.hit('a');
26
+
27
+ expect(window.hit('a')).toBe(true);
28
+ expect(window.hit('b')).toBe(false);
29
+ });
30
+
31
+ it('drains: an attempt older than the window no longer counts, and refused attempts never counted', () => {
32
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 2, now: clock });
33
+ window.hit('a');
34
+ now += 4 * MINUTE;
35
+ window.hit('a');
36
+ now += 5 * MINUTE;
37
+ expect(window.hit('a')).toBe(true); // both counted attempts are still inside the window
38
+
39
+ now += 1 * MINUTE + 1; // the first counted attempt left the window; the refused one never counted
40
+ expect(window.hit('a')).toBe(false);
41
+ expect(window.hit('a')).toBe(true);
42
+ });
43
+
44
+ it('isOver reads without counting; record counts without answering; clear forgets the key', () => {
45
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 2, now: clock });
46
+
47
+ expect(window.isOver('a')).toBe(false);
48
+ expect(window.isOver('a')).toBe(false);
49
+ window.record('a');
50
+ expect(window.isOver('a')).toBe(false);
51
+ window.record('a');
52
+ expect(window.isOver('a')).toBe(true);
53
+
54
+ window.clear('a');
55
+ expect(window.isOver('a')).toBe(false);
56
+ });
57
+
58
+ it('forgive uncounts the latest attempt only', () => {
59
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 2, now: clock });
60
+ window.hit('a');
61
+ window.hit('a');
62
+ expect(window.isOver('a')).toBe(true);
63
+
64
+ window.forgive('a');
65
+
66
+ expect(window.isOver('a')).toBe(false);
67
+ expect(window.hit('a')).toBe(false);
68
+ expect(window.hit('a')).toBe(true);
69
+ window.forgive('b'); // a key never seen: nothing to uncount, nothing thrown
70
+ expect(window.isOver('b')).toBe(false);
71
+ });
72
+
73
+ it('bounds the keys it tracks: at the bound the oldest-touched key is forgotten first', () => {
74
+ const window = new SlidingWindow({ windowMs: 10 * MINUTE, limit: 1, maxKeys: 2, now: clock });
75
+ window.hit('a');
76
+ window.hit('b');
77
+ window.hit('a'); // refused, but 'a' is now the most recently touched
78
+
79
+ window.hit('c'); // at the bound: 'b' (oldest-touched) is forgotten
80
+
81
+ expect(window.isOver('a')).toBe(true);
82
+ expect(window.isOver('b')).toBe(false);
83
+ expect(window.isOver('c')).toBe(true);
84
+ });
85
+ });
package/test/setup.js CHANGED
@@ -1,2 +1,4 @@
1
1
  process.env.SPANNER_EMULATOR_HOST = process.env.SPANNER_EMULATOR_HOST || 'localhost:9010';
2
2
  process.env.DB_LOG_LEVEL = 'error';
3
+ // The key the throttled doors' digests (RequestDigests) derive from; a process without one refuses to digest.
4
+ process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'user-server-test-secret';