@nestjs/authentication 0.0.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 (215) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +50 -0
  3. package/dist/account/email-verification.handler.d.ts +23 -0
  4. package/dist/account/email-verification.handler.js +17 -0
  5. package/dist/account/email-verification.service.d.ts +46 -0
  6. package/dist/account/email-verification.service.js +111 -0
  7. package/dist/account/email.util.d.ts +14 -0
  8. package/dist/account/email.util.js +28 -0
  9. package/dist/account/in-memory-email-token.store.d.ts +12 -0
  10. package/dist/account/in-memory-email-token.store.js +60 -0
  11. package/dist/account/password-reset.handler.d.ts +19 -0
  12. package/dist/account/password-reset.handler.js +9 -0
  13. package/dist/account/password-reset.service.d.ts +69 -0
  14. package/dist/account/password-reset.service.js +204 -0
  15. package/dist/authentication.constants.d.ts +13 -0
  16. package/dist/authentication.constants.js +13 -0
  17. package/dist/authentication.module-definition.d.ts +21 -0
  18. package/dist/authentication.module-definition.js +147 -0
  19. package/dist/authentication.module.d.ts +34 -0
  20. package/dist/authentication.module.js +139 -0
  21. package/dist/context/authentication-scope.service.d.ts +39 -0
  22. package/dist/context/authentication-scope.service.js +64 -0
  23. package/dist/context/authentication.context.d.ts +28 -0
  24. package/dist/context/authentication.context.js +58 -0
  25. package/dist/context/index.d.ts +2 -0
  26. package/dist/context/index.js +2 -0
  27. package/dist/decorators/authenticate.decorator.d.ts +10 -0
  28. package/dist/decorators/authenticate.decorator.js +18 -0
  29. package/dist/decorators/current-session.decorator.d.ts +2 -0
  30. package/dist/decorators/current-session.decorator.js +6 -0
  31. package/dist/decorators/current-user.decorator.d.ts +6 -0
  32. package/dist/decorators/current-user.decorator.js +11 -0
  33. package/dist/decorators/index.d.ts +4 -0
  34. package/dist/decorators/index.js +4 -0
  35. package/dist/decorators/public.decorator.d.ts +5 -0
  36. package/dist/decorators/public.decorator.js +6 -0
  37. package/dist/errors/authentication.error.d.ts +27 -0
  38. package/dist/errors/authentication.error.js +33 -0
  39. package/dist/errors/index.d.ts +7 -0
  40. package/dist/errors/index.js +7 -0
  41. package/dist/errors/jwt.error.d.ts +9 -0
  42. package/dist/errors/jwt.error.js +12 -0
  43. package/dist/errors/magic-link.error.d.ts +18 -0
  44. package/dist/errors/magic-link.error.js +21 -0
  45. package/dist/errors/mfa-already-enrolled.error.d.ts +14 -0
  46. package/dist/errors/mfa-already-enrolled.error.js +17 -0
  47. package/dist/errors/oidc.error.d.ts +15 -0
  48. package/dist/errors/oidc.error.js +13 -0
  49. package/dist/errors/refresh-token.error.d.ts +10 -0
  50. package/dist/errors/refresh-token.error.js +12 -0
  51. package/dist/errors/secret-decryption.error.d.ts +3 -0
  52. package/dist/errors/secret-decryption.error.js +6 -0
  53. package/dist/events/authentication-events.interface.d.ts +121 -0
  54. package/dist/events/authentication-events.interface.js +1 -0
  55. package/dist/events/authentication-events.service.d.ts +19 -0
  56. package/dist/events/authentication-events.service.js +39 -0
  57. package/dist/events/authentication.channels.d.ts +4 -0
  58. package/dist/events/authentication.channels.js +11 -0
  59. package/dist/events/index.d.ts +3 -0
  60. package/dist/events/index.js +3 -0
  61. package/dist/guards/authentication.guard.d.ts +57 -0
  62. package/dist/guards/authentication.guard.js +171 -0
  63. package/dist/guards/index.d.ts +1 -0
  64. package/dist/guards/index.js +1 -0
  65. package/dist/index.d.ts +52 -0
  66. package/dist/index.js +52 -0
  67. package/dist/interceptors/authentication-scope.interceptor.d.ts +20 -0
  68. package/dist/interceptors/authentication-scope.interceptor.js +48 -0
  69. package/dist/interceptors/index.d.ts +1 -0
  70. package/dist/interceptors/index.js +1 -0
  71. package/dist/interfaces/api-key.interface.d.ts +41 -0
  72. package/dist/interfaces/api-key.interface.js +1 -0
  73. package/dist/interfaces/authenticate-options.interface.d.ts +31 -0
  74. package/dist/interfaces/authenticate-options.interface.js +1 -0
  75. package/dist/interfaces/authentication-error-options.interface.d.ts +8 -0
  76. package/dist/interfaces/authentication-error-options.interface.js +1 -0
  77. package/dist/interfaces/authentication-module-options.interface.d.ts +58 -0
  78. package/dist/interfaces/authentication-module-options.interface.js +1 -0
  79. package/dist/interfaces/authentication-registry.interface.d.ts +41 -0
  80. package/dist/interfaces/authentication-registry.interface.js +1 -0
  81. package/dist/interfaces/authentication-result.interface.d.ts +25 -0
  82. package/dist/interfaces/authentication-result.interface.js +1 -0
  83. package/dist/interfaces/authentication-storage.interface.d.ts +29 -0
  84. package/dist/interfaces/authentication-storage.interface.js +1 -0
  85. package/dist/interfaces/duration.interface.d.ts +2 -0
  86. package/dist/interfaces/duration.interface.js +1 -0
  87. package/dist/interfaces/email-token-store.interface.d.ts +52 -0
  88. package/dist/interfaces/email-token-store.interface.js +1 -0
  89. package/dist/interfaces/email-verification.interface.d.ts +18 -0
  90. package/dist/interfaces/email-verification.interface.js +1 -0
  91. package/dist/interfaces/index.d.ts +27 -0
  92. package/dist/interfaces/index.js +27 -0
  93. package/dist/interfaces/jwt-options.interface.d.ts +68 -0
  94. package/dist/interfaces/jwt-options.interface.js +1 -0
  95. package/dist/interfaces/jwt.interface.d.ts +36 -0
  96. package/dist/interfaces/jwt.interface.js +1 -0
  97. package/dist/interfaces/magic-link-store.interface.d.ts +36 -0
  98. package/dist/interfaces/magic-link-store.interface.js +1 -0
  99. package/dist/interfaces/magic-link.interface.d.ts +55 -0
  100. package/dist/interfaces/magic-link.interface.js +1 -0
  101. package/dist/interfaces/mfa-options.interface.d.ts +44 -0
  102. package/dist/interfaces/mfa-options.interface.js +1 -0
  103. package/dist/interfaces/mfa-store.interface.d.ts +70 -0
  104. package/dist/interfaces/mfa-store.interface.js +1 -0
  105. package/dist/interfaces/oidc-provider.interface.d.ts +56 -0
  106. package/dist/interfaces/oidc-provider.interface.js +1 -0
  107. package/dist/interfaces/oidc-state-store.interface.d.ts +25 -0
  108. package/dist/interfaces/oidc-state-store.interface.js +1 -0
  109. package/dist/interfaces/oidc.interface.d.ts +84 -0
  110. package/dist/interfaces/oidc.interface.js +1 -0
  111. package/dist/interfaces/password-hasher-options.interface.d.ts +17 -0
  112. package/dist/interfaces/password-hasher-options.interface.js +1 -0
  113. package/dist/interfaces/password-reset.interface.d.ts +49 -0
  114. package/dist/interfaces/password-reset.interface.js +1 -0
  115. package/dist/interfaces/refresh-token-options.interface.d.ts +10 -0
  116. package/dist/interfaces/refresh-token-options.interface.js +1 -0
  117. package/dist/interfaces/refresh-token-store.interface.d.ts +61 -0
  118. package/dist/interfaces/refresh-token-store.interface.js +1 -0
  119. package/dist/interfaces/session-store.interface.d.ts +53 -0
  120. package/dist/interfaces/session-store.interface.js +1 -0
  121. package/dist/interfaces/session.interface.d.ts +50 -0
  122. package/dist/interfaces/session.interface.js +1 -0
  123. package/dist/interfaces/sign-in-options.interface.d.ts +9 -0
  124. package/dist/interfaces/sign-in-options.interface.js +1 -0
  125. package/dist/interfaces/token.interface.d.ts +30 -0
  126. package/dist/interfaces/token.interface.js +1 -0
  127. package/dist/jwt/amr.util.d.ts +9 -0
  128. package/dist/jwt/amr.util.js +11 -0
  129. package/dist/jwt/in-memory-refresh-token.store.d.ts +29 -0
  130. package/dist/jwt/in-memory-refresh-token.store.js +67 -0
  131. package/dist/jwt/jwks.client.d.ts +34 -0
  132. package/dist/jwt/jwks.client.js +154 -0
  133. package/dist/jwt/jws.util.d.ts +45 -0
  134. package/dist/jwt/jws.util.js +282 -0
  135. package/dist/jwt/jwt-bearer.provider.d.ts +48 -0
  136. package/dist/jwt/jwt-bearer.provider.js +126 -0
  137. package/dist/jwt/jwt-signer.service.d.ts +19 -0
  138. package/dist/jwt/jwt-signer.service.js +36 -0
  139. package/dist/jwt/jwt-verifier.service.d.ts +22 -0
  140. package/dist/jwt/jwt-verifier.service.js +109 -0
  141. package/dist/jwt/token.service.d.ts +81 -0
  142. package/dist/jwt/token.service.js +230 -0
  143. package/dist/magic-link/in-memory-magic-link.store.d.ts +12 -0
  144. package/dist/magic-link/in-memory-magic-link.store.js +45 -0
  145. package/dist/magic-link/magic-link.handler.d.ts +25 -0
  146. package/dist/magic-link/magic-link.handler.js +14 -0
  147. package/dist/magic-link/magic-link.service.d.ts +79 -0
  148. package/dist/magic-link/magic-link.service.js +215 -0
  149. package/dist/mfa/in-memory-mfa.store.d.ts +25 -0
  150. package/dist/mfa/in-memory-mfa.store.js +73 -0
  151. package/dist/mfa/mfa.service.d.ts +104 -0
  152. package/dist/mfa/mfa.service.js +346 -0
  153. package/dist/mfa/otp.util.d.ts +7 -0
  154. package/dist/mfa/otp.util.js +52 -0
  155. package/dist/mfa/secret-cipher.service.d.ts +28 -0
  156. package/dist/mfa/secret-cipher.service.js +106 -0
  157. package/dist/oidc/in-memory-oidc-state.store.d.ts +13 -0
  158. package/dist/oidc/in-memory-oidc-state.store.js +45 -0
  159. package/dist/oidc/oidc-account.resolver.d.ts +23 -0
  160. package/dist/oidc/oidc-account.resolver.js +16 -0
  161. package/dist/oidc/oidc.client.d.ts +44 -0
  162. package/dist/oidc/oidc.client.js +286 -0
  163. package/dist/oidc/oidc.presets.d.ts +24 -0
  164. package/dist/oidc/oidc.presets.js +53 -0
  165. package/dist/oidc/oidc.service.d.ts +88 -0
  166. package/dist/oidc/oidc.service.js +259 -0
  167. package/dist/providers/api-key.provider.d.ts +51 -0
  168. package/dist/providers/api-key.provider.js +93 -0
  169. package/dist/providers/authentication.provider.d.ts +40 -0
  170. package/dist/providers/authentication.provider.js +33 -0
  171. package/dist/providers/index.d.ts +2 -0
  172. package/dist/providers/index.js +2 -0
  173. package/dist/services/authentication-registry.service.d.ts +75 -0
  174. package/dist/services/authentication-registry.service.js +235 -0
  175. package/dist/services/index.d.ts +3 -0
  176. package/dist/services/index.js +3 -0
  177. package/dist/services/password-hasher.service.d.ts +47 -0
  178. package/dist/services/password-hasher.service.js +170 -0
  179. package/dist/services/ws-authenticator.service.d.ts +38 -0
  180. package/dist/services/ws-authenticator.service.js +92 -0
  181. package/dist/session/cookies.util.d.ts +34 -0
  182. package/dist/session/cookies.util.js +82 -0
  183. package/dist/session/in-memory-session.store.d.ts +33 -0
  184. package/dist/session/in-memory-session.store.js +60 -0
  185. package/dist/session/session-cookie.provider.d.ts +36 -0
  186. package/dist/session/session-cookie.provider.js +58 -0
  187. package/dist/session/session.service.d.ts +97 -0
  188. package/dist/session/session.service.js +208 -0
  189. package/dist/session/sign-in.service.d.ts +87 -0
  190. package/dist/session/sign-in.service.js +206 -0
  191. package/dist/storage/authentication.storage.d.ts +130 -0
  192. package/dist/storage/authentication.storage.js +350 -0
  193. package/dist/storage/index.d.ts +1 -0
  194. package/dist/storage/index.js +1 -0
  195. package/dist/testing/index.d.ts +39 -0
  196. package/dist/testing/index.js +531 -0
  197. package/dist/utils/api-key.util.d.ts +10 -0
  198. package/dist/utils/api-key.util.js +10 -0
  199. package/dist/utils/auth-state.util.d.ts +54 -0
  200. package/dist/utils/auth-state.util.js +209 -0
  201. package/dist/utils/bearer.util.d.ts +9 -0
  202. package/dist/utils/bearer.util.js +21 -0
  203. package/dist/utils/cross-origin.util.d.ts +29 -0
  204. package/dist/utils/cross-origin.util.js +90 -0
  205. package/dist/utils/crypto.util.d.ts +19 -0
  206. package/dist/utils/crypto.util.js +38 -0
  207. package/dist/utils/duration.util.d.ts +9 -0
  208. package/dist/utils/duration.util.js +44 -0
  209. package/dist/utils/index.d.ts +8 -0
  210. package/dist/utils/index.js +8 -0
  211. package/dist/utils/options.util.d.ts +20 -0
  212. package/dist/utils/options.util.js +44 -0
  213. package/dist/utils/transport-errors.util.d.ts +33 -0
  214. package/dist/utils/transport-errors.util.js +70 -0
  215. package/package.json +108 -0
@@ -0,0 +1,531 @@
1
+ /**
2
+ * `@nestjs/authentication/testing`: the contract every store must honour, as test cases that run
3
+ * under any test runner. Each case throws (a `node:assert` error) on failure:
4
+ *
5
+ * ```ts
6
+ * import { authenticationStoreContract } from '@nestjs/authentication/testing';
7
+ *
8
+ * const cases = authenticationStoreContract(async () => ({ sessions: store, mfa: store }), {
9
+ * contracts: ['sessions', 'mfa'],
10
+ * concurrent: true,
11
+ * });
12
+ * for (const c of cases) it(c.name, c.run); // Vitest, Jest
13
+ * for (const c of cases) test(c.name, c.run); // node:test
14
+ * ```
15
+ */
16
+ import assert from 'node:assert/strict';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { EmailVerificationService } from '../account/email-verification.service.js';
19
+ import { EmailVerificationHandler } from '../account/email-verification.handler.js';
20
+ import { PasswordResetService } from '../account/password-reset.service.js';
21
+ import { PasswordResetHandler } from '../account/password-reset.handler.js';
22
+ import { AuthenticationStorage, LOCK_STORAGE } from '../storage/authentication.storage.js';
23
+ import { AuthenticationRegistry, LOCK_REGISTRY } from '../services/authentication-registry.service.js';
24
+ import { AuthenticationEvents } from '../events/authentication-events.service.js';
25
+ import { TokenService } from '../jwt/token.service.js';
26
+ import { MfaService } from '../mfa/mfa.service.js';
27
+ import { base32Decode, hotp } from '../mfa/otp.util.js';
28
+ import { PasswordHasher } from '../services/password-hasher.service.js';
29
+ import { SessionService } from '../session/session.service.js';
30
+ import { SignInService } from '../session/sign-in.service.js';
31
+ const ALL = ['sessions', 'refreshTokens', 'mfa', 'magicLinks', 'oidcStates', 'emailTokens'];
32
+ const SECRET = 'contract-suite-secret-of-at-least-32-bytes!';
33
+ const T0 = 1_700_000_000_000;
34
+ const at = (ms) => new Date(T0 + ms);
35
+ /**
36
+ * The cases every implementation of the given contracts must pass. Ids and
37
+ * user ids are unique per case, so a store on a shared database works, but
38
+ * the `maxPending` cases need the pending tables empty: return stores on
39
+ * fresh state (new tables, or truncated ones) from `create()`.
40
+ */
41
+ export function authenticationStoreContract(create, options = {}) {
42
+ const contracts = options.contracts ?? ALL;
43
+ for (const contract of contracts) {
44
+ if (!ALL.includes(contract)) {
45
+ throw new TypeError(`authenticationStoreContract(): unknown contract \`${contract}\``);
46
+ }
47
+ }
48
+ const cases = [];
49
+ const add = (contract, name, run, when = true) => {
50
+ if (!when || !contracts.includes(contract)) {
51
+ return;
52
+ }
53
+ cases.push({
54
+ contract,
55
+ name: `${contract}: ${name}`,
56
+ run: async () => run(await fresh(create, contract)),
57
+ });
58
+ };
59
+ const concurrent = options.concurrent === true;
60
+ const maxPending = options.maxPending;
61
+ if (maxPending !== undefined && !(Number.isSafeInteger(maxPending) && maxPending > 0)) {
62
+ throw new TypeError(`authenticationStoreContract(): maxPending must be a positive integer (got ${maxPending})`);
63
+ }
64
+ // ---------------------------------------------------------------- sessions
65
+ add('sessions', 'round-trips every field; lists and deletes by user', async ({ sessions: store }) => {
66
+ const [u1, u2] = [uid(), uid()];
67
+ const full = session(u1, { mfa: 'pending', metadata: { userAgent: "curl/8.7.1 'O'Reilly' ü", ip: '::1', tags: ['a', 1] } });
68
+ const bare = session(u1, { createdAt: at(1), lastActiveAt: at(1) });
69
+ const other = session(u2);
70
+ for (const record of [full, bare, other]) {
71
+ await store.createSession(record);
72
+ }
73
+ same(await store.getSession(full.id), full, 'getSession() returns every field as saved');
74
+ same(await store.getSession(bare.id), bare, 'optional fields come back absent, not null');
75
+ same(await store.getSession(id()), undefined, 'getSession() of an unknown id');
76
+ same(sortIds(await store.listUserSessions(u1)), [full.id, bare.id].sort(), "listUserSessions() returns the user's sessions");
77
+ same(await store.listUserSessions(uid()), [], 'listUserSessions() of a user without sessions');
78
+ same(await store.deleteSession(bare.id), true, 'deleteSession() resolves true when it deleted the session');
79
+ same(await store.getSession(bare.id), undefined, 'deleteSession() deletes');
80
+ same(await store.deleteSession(bare.id), false, 'deleteSession() of a session already gone resolves false');
81
+ same(await store.deleteSession(id()), false, 'deleteSession() of an unknown id resolves false');
82
+ await store.deleteUserSessions(u1);
83
+ same(await store.listUserSessions(u1), [], "deleteUserSessions() deletes the user's sessions");
84
+ same(await store.getSession(other.id), other, "deleteUserSessions() leaves other users' sessions alone");
85
+ });
86
+ add('sessions', 'touchSession() moves lastActiveAt forward only, and never recreates a deleted session', async ({ sessions: store }) => {
87
+ const record = session(uid(), { mfa: 'verified', metadata: { device: 'phone' } });
88
+ await store.createSession(record);
89
+ await store.touchSession(record.id, at(5_000));
90
+ same(await store.getSession(record.id), { ...record, lastActiveAt: at(5_000) }, 'touchSession() changes lastActiveAt, nothing else');
91
+ await store.touchSession(record.id, at(1_000));
92
+ same((await store.getSession(record.id))?.lastActiveAt, at(5_000), 'touchSession() never moves lastActiveAt back');
93
+ await store.deleteSession(record.id);
94
+ await store.touchSession(record.id, at(10_000));
95
+ same(await store.getSession(record.id), undefined, 'touchSession() of a deleted session must not bring it back');
96
+ await store.touchSession(id(), at(10_000)); // unknown: no error
97
+ });
98
+ add('sessions', 'createSession() prunes only sessions past their absolute expiry, however long idle', async ({ sessions: store }) => {
99
+ const user = uid();
100
+ const idle = session(user, { lastActiveAt: at(0), expiresAt: at(3_600_000) });
101
+ const expired = session(user, { lastActiveAt: at(0), expiresAt: at(1_000) });
102
+ await store.createSession(idle);
103
+ await store.createSession(expired);
104
+ await store.createSession(session(user, { createdAt: at(2_000), lastActiveAt: at(2_000) }));
105
+ same(await store.getSession(idle.id), idle, 'a session idle for a long time, but within its absolute expiry, is kept');
106
+ const left = await store.getSession(expired.id);
107
+ assert.ok(left === undefined || left.expiresAt <= at(2_000), 'a pruned session is gone, or still past its expiry');
108
+ });
109
+ add('sessions', 'a session another instance touched survives a request that saw it idle (SessionService)', async (sources) => {
110
+ const storage = storageOf(sources, 'sessions');
111
+ const options = { idleTtl: '1m', absoluteTtl: '1h', touchInterval: 0 };
112
+ let clock = T0;
113
+ // Two instances: one clock a little behind the other.
114
+ const behind = new SessionService(storage, { session: { ...options, now: () => clock - 2_000 } });
115
+ const ahead = new SessionService(storage, { session: { ...options, now: () => clock } });
116
+ for (let round = 0; round < 3; round++) {
117
+ const { token } = await ahead.create(uid());
118
+ clock += 61_000; // idle for 61 s on `ahead`, 59 s on `behind`
119
+ const [seenIdle, touched] = await Promise.all([ahead.validate(token), behind.validate(token)]);
120
+ same(seenIdle, null, 'the request that saw the session idle is refused');
121
+ assert.ok(touched, 'the other instance, within the idle timeout, touches the session');
122
+ clock += 1_000;
123
+ assert.ok(await ahead.validate(token), 'the touched session is still there');
124
+ }
125
+ }, concurrent);
126
+ add('sessions', 'a request racing a sign-out or a rotation never brings the session back (SessionService)', async (sources) => {
127
+ let clock = T0;
128
+ const sessions = new SessionService(storageOf(sources, 'sessions'), {
129
+ session: { now: () => clock, idleTtl: '1m', absoluteTtl: '5m', touchInterval: '10s' },
130
+ });
131
+ const user = uid();
132
+ // The sign-out lands at a different point of each validation's read and touch.
133
+ for (const delay of [0, 1, 2, 3]) {
134
+ const signedOut = await sessions.create(user);
135
+ clock += 20_000;
136
+ await Promise.all([
137
+ sessions.validate(signedOut.token),
138
+ later(delay, () => sessions.revokeAll(user)),
139
+ later(1, () => sessions.validate(signedOut.token)),
140
+ ]);
141
+ same(await sessions.validate(signedOut.token), null, 'a session validated during "sign out everywhere" stays deleted');
142
+ const rotated = await sessions.create(user);
143
+ clock += 20_000;
144
+ await Promise.all([
145
+ sessions.validate(rotated.token),
146
+ later(delay, () => sessions.rotate(rotated.session)),
147
+ later(1, () => sessions.validate(rotated.token)),
148
+ ]);
149
+ same(await sessions.validate(rotated.token), null, 'a session validated during its rotation stays deleted');
150
+ }
151
+ }, concurrent);
152
+ add('sessions', 'of concurrent deletes of one session, exactly one resolves true (what keeps a rotation from outliving a revocation)', async ({ sessions: store }) => {
153
+ for (let round = 0; round < 3; round++) {
154
+ const record = session(uid());
155
+ await store.createSession(record);
156
+ const results = await Promise.all(Array.from({ length: 6 }, (_, i) => later(i % 3, () => store.deleteSession(record.id))));
157
+ same(results.filter((deleted) => deleted === true).length, 1, 'one deleteSession() call deleted it, the others found it gone');
158
+ }
159
+ }, concurrent);
160
+ add('sessions', 'a rotation racing a sign-out leaves no session, and concurrent rotations leave one (SessionService)', async (sources) => {
161
+ const sessions = new SessionService(storageOf(sources, 'sessions'), { session: { idleTtl: '1m', absoluteTtl: '5m', touchInterval: '10s' } });
162
+ for (const delay of [0, 1, 2, 3]) {
163
+ const user = uid();
164
+ const { session: revoked } = await sessions.create(user);
165
+ await Promise.all([sessions.rotate(revoked), later(delay, () => sessions.revokeAll(user))]);
166
+ same(await sessions.list(user), [], 'no session outlives "sign out everywhere", rotated or not');
167
+ const other = uid();
168
+ const { session: forked } = await sessions.create(other);
169
+ const rotations = await Promise.all([sessions.rotate(forked), later(delay, () => sessions.rotate(forked))]);
170
+ same(rotations.filter((issued) => issued !== null).length, 1, 'one of two concurrent rotations wins');
171
+ same((await sessions.list(other)).length, 1, 'a session rotated twice at once does not fork');
172
+ }
173
+ }, concurrent);
174
+ // ---------------------------------------------------------------- refresh tokens
175
+ add('refreshTokens', 'round-trips every field', async ({ refreshTokens: store }) => {
176
+ const [user, family] = [uid(), id()];
177
+ const full = refreshToken(user, family, { usedAt: at(10), claims: { amr: ['pwd', 'mfa'], auth_time: 1_700_000_000, nested: { a: [1, 'b'] } } });
178
+ const bare = refreshToken(user, family);
179
+ await store.saveRefreshToken(full);
180
+ await store.saveRefreshToken(bare);
181
+ same(await store.getRefreshToken(full.id), full, 'getRefreshToken() returns every field as saved');
182
+ same(await store.getRefreshToken(bare.id), bare, 'optional fields come back absent, not null');
183
+ same(await store.getRefreshToken(id()), undefined, 'getRefreshToken() of an unknown id');
184
+ });
185
+ add('refreshTokens', 'markRefreshTokenUsed() succeeds once', async ({ refreshTokens: store }) => {
186
+ const record = refreshToken(uid(), id());
187
+ await store.saveRefreshToken(record);
188
+ same(await store.markRefreshTokenUsed(record.id, at(5)), true, 'the first markRefreshTokenUsed() succeeds');
189
+ same((await store.getRefreshToken(record.id))?.usedAt, at(5), 'usedAt is saved');
190
+ same(await store.markRefreshTokenUsed(record.id, at(6)), false, 'a second markRefreshTokenUsed() fails');
191
+ same((await store.getRefreshToken(record.id))?.usedAt, at(5), 'a failed markRefreshTokenUsed() changes nothing');
192
+ same(await store.markRefreshTokenUsed(id(), at(6)), false, 'markRefreshTokenUsed() of an unknown id');
193
+ });
194
+ add('refreshTokens', 'markRefreshTokenUsed() succeeds once under concurrency', async ({ refreshTokens: store }) => {
195
+ for (let round = 0; round < 3; round++) {
196
+ const record = refreshToken(uid(), id());
197
+ await store.saveRefreshToken(record);
198
+ const results = await Promise.all(Array.from({ length: 8 }, (_, i) => store.markRefreshTokenUsed(record.id, at(i + 1))));
199
+ same(results.filter(Boolean).length, 1, 'exactly one of 8 parallel markRefreshTokenUsed() calls succeeds');
200
+ }
201
+ }, concurrent);
202
+ add('refreshTokens', 'a revoked family stays revoked for tokens saved later; revokeUserRefreshTokens() covers every family of the user', async ({ refreshTokens: store }) => {
203
+ const [u1, u2] = [uid(), uid()];
204
+ const [f1, f2, f3] = [id(), id(), id()];
205
+ await store.saveRefreshToken(refreshToken(u1, f1));
206
+ await store.saveRefreshToken(refreshToken(u1, f2));
207
+ await store.saveRefreshToken(refreshToken(u2, f3));
208
+ same(await store.isRefreshTokenFamilyRevoked(f1), false, 'a new family is not revoked');
209
+ await store.revokeRefreshTokenFamily(f1);
210
+ await store.revokeRefreshTokenFamily(f1); // idempotent
211
+ await store.saveRefreshToken(refreshToken(u1, f1)); // a successor, saved by a refresh that lost the race
212
+ same(await store.isRefreshTokenFamilyRevoked(f1), true, 'a family stays revoked after a successor is saved');
213
+ same(await store.isRefreshTokenFamilyRevoked(f2), false, "revoking a family leaves the user's others alone");
214
+ await store.revokeUserRefreshTokens(u1);
215
+ same(await store.isRefreshTokenFamilyRevoked(f2), true, "revokeUserRefreshTokens() revokes each of the user's families");
216
+ same(await store.isRefreshTokenFamilyRevoked(f3), false, "revokeUserRefreshTokens() leaves other users' families alone");
217
+ const later = id();
218
+ await store.saveRefreshToken(refreshToken(u1, later));
219
+ same(await store.isRefreshTokenFamilyRevoked(later), false, 'a family started after revokeUserRefreshTokens() works');
220
+ });
221
+ add('refreshTokens', 'concurrent refreshes of one token revoke the family (TokenService)', async (sources) => {
222
+ const tokens = new TokenService(storageOf(sources, 'refreshTokens'), {
223
+ accessToken: { key: SECRET },
224
+ refreshToken: { now: () => T0 },
225
+ });
226
+ for (let round = 0; round < 3; round++) {
227
+ const first = await tokens.issue(uid());
228
+ const results = await Promise.allSettled(Array.from({ length: 4 }, () => tokens.refresh(first.refreshToken)));
229
+ const won = results.filter((r) => r.status === 'fulfilled');
230
+ same(won.length, 1, 'exactly one of 4 parallel refreshes of one token gets a new pair');
231
+ for (const lost of results) {
232
+ if (lost.status === 'rejected') {
233
+ assert.match(String(lost.reason.reason), /^(reused|invalid)$/, 'the others are refused as reused');
234
+ }
235
+ }
236
+ const { value } = won[0];
237
+ await assert.rejects(tokens.refresh(value.refreshToken), { reason: 'invalid' }, "the winner's new token is refused: the family was revoked");
238
+ }
239
+ }, concurrent);
240
+ // ---------------------------------------------------------------- MFA
241
+ add('mfa', 'round-trips the authenticator; claimTotpStep() only moves forward; saveTotp() never lowers the claimed step', async ({ mfa: store }) => {
242
+ const user = uid();
243
+ same(await store.getTotp(user), undefined, 'getTotp() of a user without an authenticator');
244
+ same(await store.claimTotpStep(user, 5), false, 'claimTotpStep() without an authenticator fails');
245
+ await store.saveTotp(user, { secret: 'v1.abc.def', confirmed: false });
246
+ same(await store.getTotp(user), { secret: 'v1.abc.def', confirmed: false }, 'optional fields come back absent, not null');
247
+ same(await store.claimTotpStep(user, 100), true, 'the first claim of a step succeeds');
248
+ same(await store.claimTotpStep(user, 100), false, 'a step is claimed once');
249
+ same(await store.claimTotpStep(user, 99), false, 'an earlier step is refused');
250
+ same(await store.claimTotpStep(user, 101), true, 'a later step succeeds');
251
+ // A record read before the claims must not reopen their steps.
252
+ await store.saveTotp(user, { secret: 'v1.new.sec', confirmed: true, pendingSecret: 'v1.p.q', lastUsedStep: 50 });
253
+ same(await store.getTotp(user), { secret: 'v1.new.sec', confirmed: true, pendingSecret: 'v1.p.q', lastUsedStep: 101 }, 'saveTotp() keeps the larger of the stored and the saved lastUsedStep');
254
+ await store.saveTotp(user, { secret: 'v1.new.sec', confirmed: true });
255
+ same((await store.getTotp(user))?.lastUsedStep, 101, 'saveTotp() without lastUsedStep keeps the stored one');
256
+ same((await store.getTotp(user))?.pendingSecret, undefined, 'saveTotp() clears pendingSecret when the record has none');
257
+ await store.saveTotp(user, { secret: 'v1.new.sec', confirmed: true, lastUsedStep: 200 });
258
+ same((await store.getTotp(user))?.lastUsedStep, 200, 'saveTotp() raises lastUsedStep');
259
+ await store.saveTotp(user, null);
260
+ same(await store.getTotp(user), undefined, 'saveTotp(null) deletes the authenticator');
261
+ await store.saveTotp(user, { secret: 'v1.again', confirmed: false });
262
+ same(await store.getTotp(user), { secret: 'v1.again', confirmed: false }, 'a new enrollment starts with no claimed step');
263
+ });
264
+ add('mfa', 'claimTotpStep() succeeds once under concurrency', async ({ mfa: store }) => {
265
+ const user = uid();
266
+ await store.saveTotp(user, { secret: 'v1.abc.def', confirmed: true });
267
+ for (const step of [100, 101, 102]) {
268
+ const claims = await Promise.all(Array.from({ length: 6 }, () => store.claimTotpStep(user, step)));
269
+ same(claims.filter(Boolean).length, 1, `exactly one of 6 parallel claims of step ${step} succeeds`);
270
+ }
271
+ const racing = await Promise.all([store.claimTotpStep(user, 103), store.saveTotp(user, { secret: 'v1.abc.def', confirmed: true, lastUsedStep: 102 })]);
272
+ same(racing[0], true, 'the claim succeeds');
273
+ same((await store.getTotp(user))?.lastUsedStep, 103, 'a saveTotp() racing a claim never reopens the claimed step');
274
+ }, concurrent);
275
+ add('mfa', 'recovery codes are replaced as a batch and consumed once, per user', async ({ mfa: store }) => {
276
+ const [u1, u2] = [uid(), uid()];
277
+ same(await store.countRecoveryCodes(u1), 0, 'a user without codes has 0');
278
+ await store.saveRecoveryCodes(u1, ['rc1.a', 'rc1.b', 'rc1.c']);
279
+ await store.saveRecoveryCodes(u2, ['rc1.a']);
280
+ same(await store.countRecoveryCodes(u1), 3, 'countRecoveryCodes() counts the batch');
281
+ same(await store.consumeRecoveryCode(u1, 'rc1.a'), true, 'a code is consumed');
282
+ same(await store.consumeRecoveryCode(u1, 'rc1.a'), false, 'a code is consumed once');
283
+ same(await store.countRecoveryCodes(u2), 1, "another user's code with the same hash is theirs");
284
+ same(await store.consumeRecoveryCode(u1, 'rc1.zzz'), false, 'an unknown code');
285
+ await store.saveRecoveryCodes(u1, ['rc1.x']);
286
+ same(await store.consumeRecoveryCode(u1, 'rc1.b'), false, 'a new batch replaces the old one');
287
+ same(await store.consumeRecoveryCode(u1, 'rc1.x'), true, 'the new batch works');
288
+ await store.saveRecoveryCodes(u1, ['rc1.y']);
289
+ await store.saveRecoveryCodes(u1, []);
290
+ same(await store.countRecoveryCodes(u1), 0, 'an empty batch deletes the codes');
291
+ });
292
+ add('mfa', 'consumeRecoveryCode() succeeds once under concurrency', async ({ mfa: store }) => {
293
+ const user = uid();
294
+ await store.saveRecoveryCodes(user, ['rc1.a', 'rc1.b']);
295
+ const consumed = await Promise.all(Array.from({ length: 6 }, () => store.consumeRecoveryCode(user, 'rc1.a')));
296
+ same(consumed.filter(Boolean).length, 1, 'exactly one of 6 parallel consumeRecoveryCode() calls succeeds');
297
+ same(await store.countRecoveryCodes(user), 1, 'the code is gone');
298
+ }, concurrent);
299
+ add('mfa', 'recordMfaFailure() counts within the window by the caller clock; clearMfaFailures() forgets', async ({ mfa: store }) => {
300
+ const [u1, u2] = [uid(), uid()];
301
+ same(await store.recordMfaFailure(u1, 60_000, T0), 1, 'the first failure counts itself');
302
+ same(await store.recordMfaFailure(u1, 60_000, T0 + 30_000), 2, 'failures within the window add up');
303
+ same(await store.countMfaFailures(u1, 60_000, T0 + 30_000), 2, 'countMfaFailures() counts without recording');
304
+ same(await store.countMfaFailures(u1, 60_000, T0 + 30_000), 2, 'countMfaFailures() records nothing');
305
+ same(await store.countMfaFailures(u1, 60_000, T0 + 60_000), 1, 'a failure exactly windowMs old is out of the window');
306
+ same(await store.recordMfaFailure(u1, 60_000, T0 + 60_000), 2, 'recordMfaFailure() counts the window too');
307
+ same(await store.countMfaFailures(u2, 60_000, T0), 0, "failures are the user's own");
308
+ await store.clearMfaFailures(u1);
309
+ same(await store.countMfaFailures(u1, 60_000, T0 + 60_000), 0, 'clearMfaFailures() forgets');
310
+ });
311
+ add('mfa', 'parallel recordMfaFailure() calls never share a low count', async ({ mfa: store }) => {
312
+ const user = uid();
313
+ const counts = await Promise.all(Array.from({ length: 12 }, () => store.recordMfaFailure(user, 60_000, T0)));
314
+ // Each call counts itself and those recorded before it: at most k calls can see a count of k or less.
315
+ [...counts].sort((a, b) => a - b).forEach((count, i) => assert.ok(count >= i + 1, `parallel counts ${counts.join(', ')}: two calls saw the same low count`));
316
+ same(Math.max(...counts), 12, 'the last of 12 parallel failures counts all 12');
317
+ }, concurrent);
318
+ add('mfa', 'a burst of parallel guesses gets maxAttempts checks, not one per request (MfaService)', async (sources) => {
319
+ let clock = T0;
320
+ const maxAttempts = 5;
321
+ // `MfaService` reports each refused code with the count it saw (`failures`): a code was
322
+ // checked only if that count was within the limit.
323
+ const events = new AuthenticationEvents();
324
+ const seen = [];
325
+ events.events$.subscribe((event) => {
326
+ if (event.type === 'mfa-failed') {
327
+ seen.push(event.failures);
328
+ }
329
+ });
330
+ const mfa = new MfaService(storageOf(sources, 'mfa'), { mfa: { now: () => clock, maxAttempts, encryption: { keys: ['k'.repeat(32)] } } }, events);
331
+ const user = uid();
332
+ const { secret } = await mfa.enroll(user, 'ada');
333
+ const code = (offset = 0) => hotp(base32Decode(secret), Math.floor(clock / 30_000) + offset);
334
+ same(await mfa.confirm(user, code()), true, 'the authenticator is confirmed');
335
+ clock += 30_000;
336
+ same(await mfa.verifyTotp(user, code()), true, 'a code verifies');
337
+ same(await mfa.verifyTotp(user, code()), false, 'a code is accepted once');
338
+ // Eleven wrong codes at once. On a database they record in whatever order the connections
339
+ // land, so what is deterministic is how many were checked: every attempt is recorded before
340
+ // it is counted, so at most `maxAttempts` of them see a count within the limit (the others
341
+ // are refused unchecked, with a count past it). A store that counts, then records, lets all
342
+ // eleven see the same low count, and checks every one.
343
+ clock += 30_000;
344
+ seen.length = 0;
345
+ const valid = new Set([-1, 0, 1].map((offset) => code(offset)));
346
+ const wrong = Array.from({ length: 20 }, (_, i) => String(i).padStart(6, '0')).filter((guess) => !valid.has(guess));
347
+ const results = await Promise.all(wrong.slice(0, maxAttempts * 2 + 1).map((guess) => mfa.verifyTotp(user, guess)));
348
+ same(results.filter(Boolean).length, 0, 'every wrong code is refused');
349
+ const checked = seen.filter((failures) => failures < maxAttempts).length;
350
+ assert.ok(checked < maxAttempts, `of ${results.length} parallel guesses, ${checked} saw a count under maxAttempts (${maxAttempts}): recordMfaFailure() ` +
351
+ 'must record the attempt before counting, each committed on its own, so parallel guesses never share a low count');
352
+ same(await mfa.verifyTotp(user, code()), false, 'the right code is refused while the lockout lasts');
353
+ clock += 15 * 60_000;
354
+ same(await mfa.verifyTotp(user, code()), true, 'the lockout ends');
355
+ }, concurrent);
356
+ // ---------------------------------------------------------------- magic links, OIDC logins, email tokens
357
+ const pending = (contract, kind, get, make, save, consume) => {
358
+ add(contract, `hands a ${kind} out once, and drops expired ones as new ones are saved`, async (sources) => {
359
+ const store = get(sources);
360
+ const soon = make(0, 1_000);
361
+ const later = make(0, 900_000);
362
+ await save(store, soon);
363
+ await save(store, later);
364
+ same(await consume(store, later), later, `consume returns the ${kind} as saved`);
365
+ same(await consume(store, later), undefined, `a ${kind} is consumed once`);
366
+ await save(store, make(1_000, 901_000)); // created when `soon` expired
367
+ same(await consume(store, soon), undefined, `an expired ${kind} is deleted when a new one is saved`);
368
+ });
369
+ add(contract, `hands a ${kind} out once under concurrency`, async (sources) => {
370
+ const store = get(sources);
371
+ for (let round = 0; round < 3; round++) {
372
+ const record = make(0, 900_000);
373
+ await save(store, record);
374
+ const results = await Promise.all(Array.from({ length: 6 }, () => consume(store, record)));
375
+ const won = results.filter((result) => result !== undefined);
376
+ same(won.length, 1, `exactly one of 6 parallel consumes gets the ${kind}`);
377
+ same(won[0], record, `the winner gets the ${kind} as saved`);
378
+ }
379
+ }, concurrent);
380
+ add(contract, `keeps at most maxPending ${kind}s, dropping those that expire first`, async (sources) => {
381
+ const store = get(sources);
382
+ const records = Array.from({ length: maxPending + 2 }, (_, i) => make(i, 900_000 + i));
383
+ for (const record of records) {
384
+ await save(store, record);
385
+ }
386
+ same(await consume(store, records[0]), undefined, `the ${kind} that expires first is dropped`);
387
+ same(await consume(store, records[1]), undefined, `the ${kind} that expires second is dropped`);
388
+ for (const record of records.slice(2)) {
389
+ same(await consume(store, record), record, `the other ${kind}s are kept`);
390
+ }
391
+ }, maxPending !== undefined);
392
+ };
393
+ pending('magicLinks', 'link', (sources) => sources.magicLinks, (created, expires) => ({ id: id(), email: `${uid()}@example.com`, createdAt: at(created), expiresAt: at(expires), ...(created % 2 === 0 && { redirectTo: '/orders?x=1&y=ü' }) }), (store, record) => store.saveMagicLink(record), (store, record) => store.consumeMagicLink(record.id));
394
+ pending('oidcStates', 'login', (sources) => sources.oidcStates, (created, expires) => created % 2 === 0
395
+ ? {
396
+ state: id(),
397
+ provider: 'google',
398
+ nonce: id(),
399
+ codeVerifier: id(),
400
+ redirectTo: '/orders',
401
+ createdAt: at(created),
402
+ expiresAt: at(expires),
403
+ link: { userId: uid(), sessionId: id() },
404
+ }
405
+ : { state: id(), provider: 'github', codeVerifier: id(), createdAt: at(created), expiresAt: at(expires) }, (store, record) => store.saveOidcState(record), (store, record) => store.consumeOidcState(record.state));
406
+ pending('emailTokens', 'token', (sources) => sources.emailTokens, (created, expires) => emailToken({ createdAt: at(created), expiresAt: at(expires), ...(created % 2 === 0 && { fingerprint: id() }) }), (store, record) => store.saveEmailToken(record), (store, record) => store.consumeEmailToken(record.id, record.purpose));
407
+ add('emailTokens', 'a token is consumed only for its purpose; deleteUserEmailTokens() is per user and purpose', async ({ emailTokens: store }) => {
408
+ const [u1, u2] = [uid(), uid()];
409
+ const reset = emailToken({ userId: u1, fingerprint: 'fp' });
410
+ const verify = emailToken({ userId: u1, purpose: 'email-verification' });
411
+ const other = emailToken({ userId: u2 });
412
+ for (const record of [reset, verify, other]) {
413
+ await store.saveEmailToken(record);
414
+ }
415
+ same(await store.consumeEmailToken(reset.id, 'email-verification'), undefined, 'a token of another purpose is refused');
416
+ same(await store.consumeEmailToken(reset.id, 'password-reset'), reset, '…and left in place');
417
+ const second = emailToken({ userId: u1 });
418
+ await store.saveEmailToken(second);
419
+ await store.deleteUserEmailTokens(u1, 'password-reset');
420
+ same(await store.consumeEmailToken(second.id, 'password-reset'), undefined, "deleteUserEmailTokens() deletes the user's tokens of that purpose");
421
+ same(await store.consumeEmailToken(verify.id, 'email-verification'), verify, 'deleteUserEmailTokens() leaves other purposes alone');
422
+ same(await store.consumeEmailToken(other.id, 'password-reset'), other, "deleteUserEmailTokens() leaves other users' tokens alone");
423
+ });
424
+ add('emailTokens', 'password reset and verification links work once under concurrency (the services)', async (sources) => {
425
+ const storage = storageOf(sources, 'emailTokens');
426
+ const hasher = new PasswordHasher({ logN: 10 });
427
+ const account = { id: uid(), email: `${uid()}@example.com`, passwordHash: await hasher.hash('old password'), verified: false };
428
+ const mails = [];
429
+ const resets = new (class extends PasswordResetHandler {
430
+ findUser(email) {
431
+ return email === account.email ? { id: account.id, email: account.email, passwordHash: account.passwordHash } : null;
432
+ }
433
+ send({ url }) {
434
+ mails.push(url);
435
+ }
436
+ updatePassword(_, hash) {
437
+ account.passwordHash = hash;
438
+ }
439
+ })();
440
+ const verification = new (class extends EmailVerificationHandler {
441
+ send({ url }) {
442
+ mails.push(url);
443
+ }
444
+ markVerified(userId, email) {
445
+ if (userId !== account.id || email !== account.email) {
446
+ return false;
447
+ }
448
+ account.verified = true;
449
+ return true;
450
+ }
451
+ })();
452
+ const events = new AuthenticationEvents();
453
+ const sessions = new SessionService(storage, {}, events);
454
+ const tokens = new TokenService(storage, {}, events);
455
+ const signIn = new SignInService(sessions, new MfaService(storage, {}), tokens, undefined, undefined, events);
456
+ const options = { passwordReset: { url: 'https://app.test/reset' }, emailVerification: { url: 'https://app.test/verify' } };
457
+ const registry = new AuthenticationRegistry(options);
458
+ registry.registerHandler('passwordReset', resets);
459
+ registry.registerHandler('emailVerification', verification);
460
+ registry[LOCK_REGISTRY]({ log: false });
461
+ const passwordResets = new PasswordResetService(storage, registry, hasher, sessions, tokens, signIn, options, events);
462
+ const emailVerification = new EmailVerificationService(storage, registry, options, events);
463
+ const token = () => new URL(mails.at(-1)).searchParams.get('token');
464
+ await emailVerification.send(account);
465
+ const verifyToken = token();
466
+ const verified = await Promise.all(Array.from({ length: 4 }, () => emailVerification.verify(verifyToken)));
467
+ same(verified.filter(Boolean).length, 1, 'exactly one of 4 parallel verifications of one link succeeds');
468
+ same(account.verified, true, 'the address is verified');
469
+ account.verified = false;
470
+ passwordResets.request(account.email.toUpperCase());
471
+ await passwordResets.onModuleDestroy(); // waits for the request in flight
472
+ const resetToken = token();
473
+ const results = await Promise.all(Array.from({ length: 4 }, () => passwordResets.reset(resetToken, 'new password')));
474
+ same(results.filter(Boolean), [{ userId: account.id }], 'exactly one of 4 parallel resets with one link succeeds');
475
+ same(await hasher.verify('new password', account.passwordHash), true, 'the new password is set');
476
+ same(account.verified, true, 'the reset link proved the address');
477
+ }, concurrent);
478
+ return cases;
479
+ }
480
+ // ---------------------------------------------------------------- helpers
481
+ async function fresh(create, contract) {
482
+ const sources = await create();
483
+ if (!sources || typeof sources !== 'object' || !sources[contract]) {
484
+ throw new Error(`authenticationStoreContract(): create() returned no \`${contract}\` store. Return it, or leave \`${contract}\` out of \`contracts\`.`);
485
+ }
486
+ return sources;
487
+ }
488
+ /**
489
+ * A registry with `contract` from the store under test, and the in-memory
490
+ * defaults for the rest, on purpose: under `NODE_ENV=production` too.
491
+ */
492
+ function storageOf(sources, contract) {
493
+ const storage = new AuthenticationStorage({ contracts: [], allowInMemoryStorage: true });
494
+ storage.registerSource({ [contract]: sources[contract] });
495
+ storage[LOCK_STORAGE]({ log: false });
496
+ return storage;
497
+ }
498
+ const id = () => randomUUID().replaceAll('-', '');
499
+ /** Runs `fn` after `turns` turns of the event loop. */
500
+ async function later(turns, fn) {
501
+ for (let i = 0; i < turns; i++) {
502
+ await new Promise((resolve) => setImmediate(resolve));
503
+ }
504
+ return fn();
505
+ }
506
+ const uid = () => `user-${randomUUID()}`;
507
+ function session(userId, overrides = {}) {
508
+ return { id: id(), userId, createdAt: at(0), expiresAt: at(3_600_000), lastActiveAt: at(0), ...overrides };
509
+ }
510
+ function refreshToken(userId, familyId, overrides = {}) {
511
+ return { id: id(), familyId, userId, createdAt: at(0), expiresAt: at(60_000), familyExpiresAt: at(120_000), ...overrides };
512
+ }
513
+ function emailToken(overrides = {}) {
514
+ return { id: id(), purpose: 'password-reset', userId: uid(), email: `${uid()}@example.com`, createdAt: at(0), expiresAt: at(60_000), ...overrides };
515
+ }
516
+ const sortIds = (records) => records.map((record) => record.id).sort();
517
+ /** Deep equality where a missing key and an `undefined` one are the same, and `null` is not `undefined`. */
518
+ function same(actual, expected, message) {
519
+ assert.deepStrictEqual(normalize(actual), normalize(expected), message);
520
+ }
521
+ function normalize(value) {
522
+ if (value instanceof Date || value === null || typeof value !== 'object') {
523
+ return value;
524
+ }
525
+ if (Array.isArray(value)) {
526
+ return value.map(normalize);
527
+ }
528
+ return Object.fromEntries(Object.entries(value)
529
+ .filter(([, entry]) => entry !== undefined)
530
+ .map(([key, entry]) => [key, normalize(entry)]));
531
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What `ApiKeyProvider.generate()` makes: `<prefix>_<id>_<secret>`, a
3
+ * lowercase prefix of 1-16 characters, 64 random bits of public id in hex,
4
+ * and 256 random bits of secret in base64url. It has no dot, so no JWT (whose
5
+ * compact form has two) ever matches it, and `JwtBearerProvider` leaves it
6
+ * to the API key provider, whatever order the two run in.
7
+ */
8
+ export declare const API_KEY_PATTERN: RegExp;
9
+ /** What a prefix may be: the first group of {@link API_KEY_PATTERN}. */
10
+ export declare const API_KEY_PREFIX_PATTERN: RegExp;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What `ApiKeyProvider.generate()` makes: `<prefix>_<id>_<secret>`, a
3
+ * lowercase prefix of 1-16 characters, 64 random bits of public id in hex,
4
+ * and 256 random bits of secret in base64url. It has no dot, so no JWT (whose
5
+ * compact form has two) ever matches it, and `JwtBearerProvider` leaves it
6
+ * to the API key provider, whatever order the two run in.
7
+ */
8
+ export const API_KEY_PATTERN = /^([a-z][a-z0-9]{0,15})_([0-9a-f]{16})_([A-Za-z0-9_-]{43})$/;
9
+ /** What a prefix may be: the first group of {@link API_KEY_PATTERN}. */
10
+ export const API_KEY_PREFIX_PATTERN = /^[a-z][a-z0-9]{0,15}$/;
@@ -0,0 +1,54 @@
1
+ import type { ExecutionContext } from '@nestjs/common';
2
+ import type { AuthenticationResult } from '../interfaces/authentication-result.interface.js';
3
+ type Headers = Record<string, string | string[] | undefined>;
4
+ type State = AuthenticationResult<any, any> | null;
5
+ /**
6
+ * Records what the handler sees (`@CurrentUser()`, `AuthContext`,
7
+ * `request.user`). `perCallOnly` skips the carrier mirror; used for ws
8
+ * `@Public()` messages, so they do not clear `client.user` under a
9
+ * concurrent message on the same socket.
10
+ */
11
+ export declare function setAuthState(context: ExecutionContext, result: State, { perCallOnly }?: {
12
+ perCallOnly?: boolean | undefined;
13
+ }): void;
14
+ /**
15
+ * `undefined` when authentication has not run for this call, `null` when it
16
+ * ran and the caller is anonymous.
17
+ */
18
+ export declare function getAuthState(context: ExecutionContext): State | undefined;
19
+ /**
20
+ * @internal This call's result: the scope the interceptor opened for it, else what the guard
21
+ * recorded on the carrier. A ws message whose payload is a primitive shares nothing else with its
22
+ * guard but the socket, which outlives it, and would otherwise read another message's user.
23
+ */
24
+ export declare function resultOf(context: ExecutionContext): State | undefined;
25
+ /**
26
+ * The providers' unfiltered answer, cached so a guard applied twice (global
27
+ * plus `@UseGuards`, or GraphQL root and field resolvers sharing one
28
+ * operation's context) authenticates once. Per message for ws, and per
29
+ * operation for GraphQL: over graphql-ws one upgrade request serves every
30
+ * operation of the socket, and each must see a revocation.
31
+ */
32
+ export declare function setRawResult(context: ExecutionContext, result: State): void;
33
+ export declare function getRawResult(context: ExecutionContext): State | undefined;
34
+ /**
35
+ * Request headers for the transports that have them:
36
+ * - `http`: `request.headers`
37
+ * - `ws`: socket.io's `client.handshake.headers`, else `client.request.headers`
38
+ * (`WsAuthenticator.authenticateConnection()` stores the upgrade request
39
+ * there for the `ws` library)
40
+ * - `graphql`: `context.req.headers`
41
+ * - `rpc`: `undefined`; metadata is transport specific
42
+ */
43
+ export declare function getRequestHeaders(context: ExecutionContext): Headers | undefined;
44
+ /**
45
+ * The request behind a call, as far as authentication needs it: headers,
46
+ * and the method for HTTP and GraphQL (a WebSocket handshake has none, so
47
+ * it counts as a safe request). `undefined` for RPC.
48
+ */
49
+ export declare function requestOf(context: ExecutionContext): {
50
+ headers: Headers;
51
+ method?: string;
52
+ } | undefined;
53
+ export declare function firstHeader(headers: Headers | undefined, name: string): string | undefined;
54
+ export {};