@things-factory/auth-base 10.1.40 → 10.1.44

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 (39) hide show
  1. package/dist-server/controllers/reset-password.d.ts +6 -0
  2. package/dist-server/controllers/reset-password.js +12 -13
  3. package/dist-server/controllers/reset-password.js.map +1 -1
  4. package/dist-server/controllers/unlock-user.d.ts +6 -0
  5. package/dist-server/controllers/unlock-user.js +12 -13
  6. package/dist-server/controllers/unlock-user.js.map +1 -1
  7. package/dist-server/controllers/utils/find-verification-token.d.ts +16 -0
  8. package/dist-server/controllers/utils/find-verification-token.js +40 -0
  9. package/dist-server/controllers/utils/find-verification-token.js.map +1 -0
  10. package/dist-server/controllers/utils/save-verification-token.d.ts +8 -1
  11. package/dist-server/controllers/utils/save-verification-token.js +10 -2
  12. package/dist-server/controllers/utils/save-verification-token.js.map +1 -1
  13. package/dist-server/controllers/verification.d.ts +6 -0
  14. package/dist-server/controllers/verification.js +13 -8
  15. package/dist-server/controllers/verification.js.map +1 -1
  16. package/dist-server/middlewares/jwt-authenticate-middleware.js +3 -1
  17. package/dist-server/middlewares/jwt-authenticate-middleware.js.map +1 -1
  18. package/dist-server/router/auth-checkin-router.js +2 -1
  19. package/dist-server/router/auth-checkin-router.js.map +1 -1
  20. package/dist-server/router/auth-public-process-router.js +31 -8
  21. package/dist-server/router/auth-public-process-router.js.map +1 -1
  22. package/dist-server/service/index.d.ts +1 -1
  23. package/dist-server/service/role-template/index.d.ts +1 -1
  24. package/dist-server/service/verification-token/verification-token.d.ts +5 -0
  25. package/dist-server/service/verification-token/verification-token.js +6 -1
  26. package/dist-server/service/verification-token/verification-token.js.map +1 -1
  27. package/dist-server/tsconfig.tsbuildinfo +1 -1
  28. package/dist-server/utils/same-origin-redirect.d.ts +4 -0
  29. package/dist-server/utils/same-origin-redirect.js +39 -0
  30. package/dist-server/utils/same-origin-redirect.js.map +1 -0
  31. package/dist-server/utils/verification-token-rule.d.ts +16 -0
  32. package/dist-server/utils/verification-token-rule.js +73 -0
  33. package/dist-server/utils/verification-token-rule.js.map +1 -0
  34. package/package.json +4 -4
  35. package/tests/invitation-route.test.ts +36 -17
  36. package/tests/lock-recovery.test.ts +3 -1
  37. package/tests/open-fields-framework.test.ts +212 -0
  38. package/tests/open-redirect-db.test.ts +143 -0
  39. package/tests/verification-token-db.test.ts +277 -0
@@ -0,0 +1,277 @@
1
+ const sentMail: any[] = []
2
+
3
+ jest.mock('@things-factory/email-base', () => ({
4
+ sendEmail: async (mail: any) => {
5
+ sentMail.push(mail)
6
+ }
7
+ }))
8
+
9
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
10
+ import { loadCompiled } from './compiled'
11
+ import {
12
+ hashVerificationToken,
13
+ isVerificationTokenLive,
14
+ verificationTokenExpiresAt,
15
+ VERIFICATION_TOKEN_LIFETIME_MS
16
+ } from '../server/utils/verification-token-rule'
17
+
18
+ const { User } = loadCompiled('service/user/user')
19
+ const { VerificationToken, VerificationTokenType } = loadCompiled('service/verification-token/verification-token')
20
+ const { resetPassword, sendPasswordResetEmail, RESET_PASSWORD_PATH } = loadCompiled('controllers/reset-password')
21
+ const { unlockUser, sendUnlockUserEmail, UNLOCK_USER_PATH } = loadCompiled('controllers/unlock-user')
22
+ const { verify, sendVerificationEmail, VERIFY_EMAIL_PATH } = loadCompiled('controllers/verification')
23
+ const { saveVerificationToken } = loadCompiled('controllers/utils/save-verification-token')
24
+
25
+ /**
26
+ * The links in the reset, unlock and sign-up confirmation mails.
27
+ *
28
+ * Before 2026-09-24 each token lived forever, sat in the table as written and was found by that
29
+ * value, and rode in the link's query string or path. So a reset mail from last year still changed a
30
+ * password, any copy of the table was a set of live reset links, and every access log on the way
31
+ * held one too. Q4 (architect, 2026-09-24) made them work like the invitation link:
32
+ *
33
+ * stored sha256 of the token, with an `expiresAt` written once
34
+ * lifetime reset 1h · unlock 1h · activation 48h
35
+ * no expiresAt refused — rows from before this, and how links already sent stop working
36
+ * the link `<path>#<token>` built with `buildAuthURL`, so the browser never sends the token
37
+ *
38
+ * The controllers are driven with the token the mail carries, and the row is read back to see what
39
+ * the table holds. ⚠ sqlite (`tests/db.ts`).
40
+ */
41
+
42
+ const OLD = 'Passw0rd!'
43
+ const NEW = 'N3wPassw0rd!'
44
+ const HOUR = 60 * 60 * 1000
45
+
46
+ const context: any = {
47
+ t: (key: string) => key,
48
+ lng: 'en-US',
49
+ headers: {},
50
+ request: { protocol: 'https', host: 'acme.example.com' }
51
+ }
52
+
53
+ let domain: any
54
+
55
+ async function person(status = 'activated') {
56
+ const salt = User.generateSalt()
57
+
58
+ return await getRepository(User).save({
59
+ username: 'kim',
60
+ email: 'kim@example.com',
61
+ name: 'Kim',
62
+ salt,
63
+ password: User.encode(OLD, salt),
64
+ status,
65
+ failCount: status === 'locked' ? 5 : 0,
66
+ domains: [domain]
67
+ })
68
+ }
69
+
70
+ async function row(userId: string) {
71
+ return await getRepository(VerificationToken).findOneBy({ userId })
72
+ }
73
+
74
+ /** Moves a stored token's expiry — the clock is the row's, not the test's. */
75
+ async function expireAt(userId: string, at: Date | null) {
76
+ await getRepository(VerificationToken).update({ userId }, { expiresAt: at })
77
+ }
78
+
79
+ /** The token as the mail carries it: after `#` in the one link the mail holds. */
80
+ function tokenInLastMail(path: string): string {
81
+ const mail = sentMail[sentMail.length - 1]
82
+ const hrefs = [...String(mail.content).matchAll(/href="([^"]+)"/g)].map(m => m[1]).filter(h => h.includes(path))
83
+
84
+ expect(hrefs).toHaveLength(1)
85
+ const link = new URL(hrefs[0])
86
+
87
+ expect(link.host).toBe('acme.example.com')
88
+ expect(link.pathname).toBe(path)
89
+ expect(link.search).toBe('')
90
+
91
+ return link.hash.slice(1)
92
+ }
93
+
94
+ beforeAll(async () => {
95
+ await openAuthDatabase()
96
+ })
97
+
98
+ beforeEach(async () => {
99
+ await resetAuthDatabase()
100
+ sentMail.length = 0
101
+ domain = await getRepository(Domain).save({ name: 'plant-a', subdomain: 'plant-a' })
102
+ })
103
+
104
+ afterAll(async () => {
105
+ await closeAuthDatabase()
106
+ })
107
+
108
+ describe('the rule', () => {
109
+ const now = new Date('2026-09-24T12:00:00Z')
110
+
111
+ it('gives each mailed link its lifetime', () => {
112
+ expect(VERIFICATION_TOKEN_LIFETIME_MS).toEqual({ 'password-reset': HOUR, unlock: HOUR, activation: 48 * HOUR })
113
+ expect(verificationTokenExpiresAt('password-reset', now).getTime()).toBe(now.getTime() + HOUR)
114
+ })
115
+
116
+ it('refuses to make up a lifetime for a type it does not know', () => {
117
+ expect(() => verificationTokenExpiresAt('access-token', now)).toThrow(/no lifetime/)
118
+ })
119
+
120
+ it('works until the last millisecond and stops at the expiry', () => {
121
+ const expiresAt = new Date(now.getTime() + HOUR)
122
+
123
+ expect(isVerificationTokenLive({ expiresAt }, new Date(expiresAt.getTime() - 1))).toBe(true)
124
+ expect(isVerificationTokenLive({ expiresAt }, expiresAt)).toBe(false)
125
+ })
126
+
127
+ it('reads a row without an expiry as expired', () => {
128
+ expect(isVerificationTokenLive({ expiresAt: null }, now)).toBe(false)
129
+ expect(isVerificationTokenLive({}, now)).toBe(false)
130
+ expect(isVerificationTokenLive(null, now)).toBe(false)
131
+ expect(isVerificationTokenLive({ expiresAt: 'not a date' }, now)).toBe(false)
132
+ })
133
+ })
134
+
135
+ describe('what the table keeps', () => {
136
+ it('the hash of the token, and when it stops working — not the token', async () => {
137
+ const user = await person()
138
+ const before = Date.now()
139
+
140
+ await saveVerificationToken(user.id, 'a'.repeat(32), VerificationTokenType.PASSWORD_RESET)
141
+
142
+ const saved = await row(user.id)
143
+ expect(saved.token).toBe(hashVerificationToken('a'.repeat(32)))
144
+ expect(saved.token).not.toContain('a'.repeat(32))
145
+ expect(new Date(saved.expiresAt).getTime()).toBeGreaterThanOrEqual(before + HOUR)
146
+ expect(new Date(saved.expiresAt).getTime()).toBeLessThanOrEqual(Date.now() + HOUR)
147
+ })
148
+ })
149
+
150
+ describe('password reset', () => {
151
+ it('the mailed link resets the password, once', async () => {
152
+ const user = await person()
153
+ await sendPasswordResetEmail({ user, context })
154
+ const token = tokenInLastMail(RESET_PASSWORD_PATH)
155
+
156
+ await resetPassword(token, NEW, context)
157
+
158
+ const after = await getRepository(User).findOneBy({ id: user.id })
159
+ expect(User.verify(after.password, NEW, after.salt)).toBe(true)
160
+ await expect(resetPassword(token, OLD, context)).rejects.toThrow('text.invalid verification token')
161
+ })
162
+
163
+ it('refuses the link after it expires', async () => {
164
+ const user = await person()
165
+ await sendPasswordResetEmail({ user, context })
166
+ const token = tokenInLastMail(RESET_PASSWORD_PATH)
167
+
168
+ await expireAt(user.id, new Date(Date.now() - 1))
169
+
170
+ await expect(resetPassword(token, NEW, context)).rejects.toThrow('text.invalid verification token')
171
+ const after = await getRepository(User).findOneBy({ id: user.id })
172
+ expect(User.verify(after.password, OLD, after.salt)).toBe(true)
173
+ })
174
+
175
+ it('refuses a row from before — the token as written, and no expiry', async () => {
176
+ const user = await person()
177
+ await getRepository(VerificationToken).save({
178
+ userId: user.id,
179
+ token: 'b'.repeat(32),
180
+ type: VerificationTokenType.PASSWORD_RESET
181
+ })
182
+
183
+ await expect(resetPassword('b'.repeat(32), NEW, context)).rejects.toThrow('text.invalid verification token')
184
+ })
185
+
186
+ it('refuses a row whose expiry is missing, even when the hash matches', async () => {
187
+ /*
188
+ * The case above is refused twice over — the raw value does not hash to itself — so it cannot
189
+ * tell whether "no expiry" refuses. This one leaves only the expiry to decide.
190
+ */
191
+ const user = await person()
192
+ await sendPasswordResetEmail({ user, context })
193
+ const token = tokenInLastMail(RESET_PASSWORD_PATH)
194
+
195
+ await expireAt(user.id, null)
196
+
197
+ await expect(resetPassword(token, NEW, context)).rejects.toThrow('text.invalid verification token')
198
+ })
199
+
200
+ it('does not find a token by the value stored in the table', async () => {
201
+ /* ⚠ A copy of the table is not a set of reset links: what it holds is not what the link carries. */
202
+ const user = await person()
203
+ await sendPasswordResetEmail({ user, context })
204
+ const stored = (await row(user.id)).token
205
+
206
+ await expect(resetPassword(stored, NEW, context)).rejects.toThrow('text.invalid verification token')
207
+ })
208
+ })
209
+
210
+ describe('unlock', () => {
211
+ it('the mailed link unlocks the account', async () => {
212
+ const user = await person('locked')
213
+ await sendUnlockUserEmail({ user, context })
214
+
215
+ expect(await unlockUser(tokenInLastMail(UNLOCK_USER_PATH), NEW)).toBe(true)
216
+ expect((await getRepository(User).findOneBy({ id: user.id })).status).toBe('activated')
217
+ })
218
+
219
+ it('refuses the link after it expires', async () => {
220
+ const user = await person('locked')
221
+ await sendUnlockUserEmail({ user, context })
222
+ const token = tokenInLastMail(UNLOCK_USER_PATH)
223
+
224
+ await expireAt(user.id, new Date(Date.now() - 1))
225
+
226
+ expect(await unlockUser(token, NEW)).toBe(false)
227
+ expect((await getRepository(User).findOneBy({ id: user.id })).status).toBe('locked')
228
+ })
229
+ })
230
+
231
+ describe('sign-up confirmation', () => {
232
+ it('the mailed link activates the account', async () => {
233
+ const user = await person('inactive')
234
+ await sendVerificationEmail({ user, context })
235
+
236
+ await verify(tokenInLastMail(VERIFY_EMAIL_PATH))
237
+
238
+ expect((await getRepository(User).findOneBy({ id: user.id })).status).toBe('activated')
239
+ })
240
+
241
+ it('refuses the link after it expires', async () => {
242
+ const user = await person('inactive')
243
+ await sendVerificationEmail({ user, context })
244
+ const token = tokenInLastMail(VERIFY_EMAIL_PATH)
245
+
246
+ await expireAt(user.id, new Date(Date.now() - 1))
247
+
248
+ await expect(verify(token)).rejects.toThrow()
249
+ expect((await getRepository(User).findOneBy({ id: user.id })).status).toBe('inactive')
250
+ })
251
+
252
+ it('does not take a reset link for a confirmation', async () => {
253
+ /* It read the token alone, so any live token of any type activated the account. */
254
+ const user = await person('inactive')
255
+ await sendPasswordResetEmail({ user, context })
256
+
257
+ await expect(verify(tokenInLastMail(RESET_PASSWORD_PATH))).rejects.toThrow()
258
+ expect((await getRepository(User).findOneBy({ id: user.id })).status).toBe('inactive')
259
+ })
260
+ })
261
+
262
+ describe('⚠ one token per person, of any type', () => {
263
+ it('a reset request replaces a pending sign-up confirmation', async () => {
264
+ /*
265
+ * The primary key is `userId`. Pinned as it is today — the architect asked for it to be written
266
+ * down, and whether to change it is ranked in `docs/design/auth-rebuild.md` §8-7. When the key
267
+ * grows a type, this flips.
268
+ */
269
+ const user = await person('inactive')
270
+ await sendVerificationEmail({ user, context })
271
+ const confirmation = tokenInLastMail(VERIFY_EMAIL_PATH)
272
+
273
+ await sendPasswordResetEmail({ user, context })
274
+
275
+ await expect(verify(confirmation)).rejects.toThrow()
276
+ })
277
+ })