@learncard/lca-api-service 1.3.2 → 1.4.0

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 (97) hide show
  1. package/.env.example +26 -2
  2. package/AGENTS.md +59 -44
  3. package/CHANGELOG.md +41 -0
  4. package/compose.env +5 -0
  5. package/dist/accesslayer/signing-authority/index.d.ts +8 -2
  6. package/dist/app.d.ts +297 -8
  7. package/dist/cache/challenges.d.ts +2 -0
  8. package/dist/cache/recoverySessions.d.ts +42 -0
  9. package/dist/config/environment.d.ts +152 -2
  10. package/dist/config/environment.test.d.ts +1 -0
  11. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  12. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  13. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  14. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  15. package/dist/index.js +2 -2
  16. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  17. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  18. package/dist/models/EscrowHold.d.ts +75 -0
  19. package/dist/models/SigningAuthority.d.ts +7 -1
  20. package/dist/models/UserKey.d.ts +316 -3
  21. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  22. package/dist/models/index.d.ts +1 -0
  23. package/dist/routes/escrow.d.ts +150 -0
  24. package/dist/routes/index.d.ts +6 -0
  25. package/dist/routes/keys.d.ts +181 -7
  26. package/dist/routes/qr-login.d.ts +1 -1
  27. package/dist/services/escrow-enclave/index.d.ts +9 -0
  28. package/dist/services/escrow-enclave/notifications.d.ts +9 -0
  29. package/dist/services/escrow-enclave/softwareEnclave.d.ts +17 -0
  30. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  31. package/dist/services/escrow-enclave/types.d.ts +67 -0
  32. package/dist/types/seed-encryption.d.ts +31 -0
  33. package/dist/types/seed-migration.d.ts +49 -0
  34. package/lambda.ts +8 -3
  35. package/package.json +17 -11
  36. package/scripts/check-seed-encryption-infrastructure.mjs +123 -0
  37. package/scripts/check-signing-authority-redis.mjs +93 -0
  38. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  39. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  40. package/seedMigrationLambda.test.ts +145 -0
  41. package/seedMigrationLambda.ts +50 -0
  42. package/serverless-local.yml +3 -0
  43. package/serverless.escrow-env.cjs +20 -0
  44. package/serverless.yml +133 -0
  45. package/src/accesslayer/signing-authority/create.ts +11 -7
  46. package/src/app.ts +2 -0
  47. package/src/cache/challenges.ts +12 -2
  48. package/src/cache/in-memory-lru.ts +1 -0
  49. package/src/cache/recoverySessions.ts +252 -0
  50. package/src/config/environment.test.ts +116 -0
  51. package/src/config/environment.ts +97 -0
  52. package/src/docker-entry.ts +4 -0
  53. package/src/helpers/learnCard.helpers.test.ts +11 -1
  54. package/src/helpers/learnCard.helpers.ts +57 -45
  55. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  56. package/src/helpers/rateLimit.helpers.ts +112 -0
  57. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  58. package/src/helpers/seedEncryption.helpers.ts +372 -0
  59. package/src/helpers/signingAuthority.helpers.ts +4 -1
  60. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  61. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  62. package/src/models/EscrowHold.ts +225 -0
  63. package/src/models/SigningAuthority.ts +14 -3
  64. package/src/models/UserKey.escrowPin.test.ts +49 -0
  65. package/src/models/UserKey.ts +1024 -40
  66. package/src/models/index.ts +1 -0
  67. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  68. package/src/routes/credentials.ts +8 -0
  69. package/src/routes/escrow.ts +826 -0
  70. package/src/routes/firebase.ts +90 -48
  71. package/src/routes/index.ts +98 -5
  72. package/src/routes/keys.ts +1017 -174
  73. package/src/routes/qr-login.ts +51 -53
  74. package/src/services/delivery/adapters/postmark.adapter.ts +5 -0
  75. package/src/services/escrow-enclave/README.md +51 -0
  76. package/src/services/escrow-enclave/index.ts +36 -0
  77. package/src/services/escrow-enclave/notifications.ts +14 -0
  78. package/src/services/escrow-enclave/softwareEnclave.test.ts +249 -0
  79. package/src/services/escrow-enclave/softwareEnclave.ts +168 -0
  80. package/src/services/escrow-enclave/types.ts +76 -0
  81. package/src/types/seed-encryption.ts +51 -0
  82. package/src/types/seed-migration.ts +61 -0
  83. package/test/credentials.spec.ts +2 -0
  84. package/test/keys-escrow.spec.ts +1389 -0
  85. package/test/keys-identity-rebind.spec.ts +456 -0
  86. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  87. package/test/keys-recovery-confirmation.spec.ts +586 -0
  88. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  89. package/test/keys-secret-leakage.spec.ts +265 -0
  90. package/test/models/escrow.spec.ts +210 -0
  91. package/test/signing-authority-http.spec.ts +161 -0
  92. package/test/signing-authority-seed-migration.spec.ts +414 -0
  93. package/test/signing-authority.spec.ts +40 -0
  94. package/vitest.config.ts +1 -1
  95. package/vitest.integration.config.ts +5 -0
  96. package/dist/cache/signing-authority.d.ts +0 -6
  97. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,456 @@
1
+ import { randomUUID } from 'crypto';
2
+
3
+ import { client } from '@mongo';
4
+ import cache from '@cache';
5
+ import {
6
+ getRecoveryOtpAttemptsCacheKey,
7
+ getRecoveryOtpCacheKey,
8
+ getRecoveryOtpLockCacheKey,
9
+ getRecoverySessionCacheKey,
10
+ MAX_RECOVERY_OTP_ATTEMPTS,
11
+ } from '@cache/recoverySessions';
12
+ import {
13
+ createUserKeysIndexes,
14
+ getUserKeysCollection,
15
+ purgeExpiredProvisionalMigrationByAuthProvider,
16
+ type MongoUserKeyType,
17
+ } from '@models';
18
+ import * as delivery from '../src/services/delivery';
19
+
20
+ import { getClient, getUser } from './helpers/getClient';
21
+
22
+ const makeMockToken = (email: string, uid: string): string => {
23
+ const payload = Buffer.from(JSON.stringify({ sub: uid, email })).toString('base64url');
24
+
25
+ return `header.${payload}.signature`;
26
+ };
27
+
28
+ const makeRecoverableUser = (
29
+ loginEmail: string,
30
+ recoveryEmail: string,
31
+ uid: string,
32
+ primaryDid: string
33
+ ): MongoUserKeyType => {
34
+ const now = new Date();
35
+
36
+ return {
37
+ contactMethod: { type: 'email', value: loginEmail },
38
+ authProviders: [{ type: 'firebase', id: uid }],
39
+ primaryDid,
40
+ linkedDids: [],
41
+ keyProvider: 'sss',
42
+ authShare: { encryptedData: 'version-one-auth-share', encryptedDek: '', iv: '' },
43
+ shareVersion: 2,
44
+ shareUpdatedAt: now,
45
+ previousAuthShares: [
46
+ {
47
+ authShare: { encryptedData: 'version-one-auth-share', encryptedDek: '', iv: '' },
48
+ shareVersion: 1,
49
+ createdAt: now,
50
+ },
51
+ ],
52
+ securityLevel: 'enhanced',
53
+ recoveryMethods: [
54
+ {
55
+ type: 'phrase',
56
+ createdAt: now,
57
+ confirmationStatus: 'confirmed',
58
+ confirmedAt: now,
59
+ shareVersion: 1,
60
+ },
61
+ ],
62
+ recoveryEmail,
63
+ recoveryEmailVerifiedAt: now,
64
+ migratedFromWeb3Auth: false,
65
+ sssActivationState: 'active',
66
+ createdAt: now,
67
+ updatedAt: now,
68
+ };
69
+ };
70
+
71
+ beforeAll(async () => {
72
+ process.env.IS_E2E_TEST = 'true';
73
+ process.env.SEED ||= 'a'.repeat(64);
74
+ await client.connect();
75
+ await createUserKeysIndexes();
76
+ });
77
+
78
+ afterAll(async () => {
79
+ await client.close();
80
+ });
81
+
82
+ describe('P0-3 lost-identity recovery sessions', () => {
83
+ const startAndReadCode = async (recoveryEmail: string): Promise<string> => {
84
+ const send = vi.fn().mockResolvedValue(undefined);
85
+ vi.spyOn(delivery, 'getDeliveryService').mockReturnValue({ send });
86
+
87
+ await getClient().keys.startRecoverySession({ email: recoveryEmail });
88
+
89
+ const call = send.mock.calls[0]?.[0] as
90
+ { templateModel?: { verificationCode?: string } } | undefined;
91
+ const code = call?.templateModel?.verificationCode;
92
+
93
+ if (!code) throw new Error('Recovery OTP was not delivered in the test');
94
+
95
+ return code;
96
+ };
97
+
98
+ it('locks the OTP after five incorrect attempts', async () => {
99
+ const suffix = `${Date.now()}-${randomUUID()}`;
100
+ const recoveryEmail = `recovery-lock-${suffix}@example.com`;
101
+ const loginEmail = `old-lock-${suffix}@example.edu`;
102
+ const { learnCard } = await getUser('b'.repeat(64));
103
+ const collection = getUserKeysCollection();
104
+
105
+ await collection.insertOne(
106
+ makeRecoverableUser(loginEmail, recoveryEmail, `old-${suffix}`, learnCard.id.did())
107
+ );
108
+
109
+ try {
110
+ const correctCode = await startAndReadCode(recoveryEmail);
111
+
112
+ for (let attempt = 1; attempt <= 4; attempt += 1) {
113
+ await expect(
114
+ getClient().keys.verifyRecoverySession({ email: recoveryEmail, code: '000000' })
115
+ ).rejects.toMatchObject({ code: 'BAD_REQUEST' });
116
+ }
117
+
118
+ await expect(
119
+ getClient().keys.verifyRecoverySession({ email: recoveryEmail, code: '000000' })
120
+ ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
121
+ await expect(
122
+ getClient().keys.verifyRecoverySession({ email: recoveryEmail, code: correctCode })
123
+ ).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
124
+ } finally {
125
+ vi.restoreAllMocks();
126
+ await collection.deleteMany({ recoveryEmail });
127
+ }
128
+ });
129
+
130
+ it('atomically caps concurrent incorrect OTP attempts and engages the lock', async () => {
131
+ const suffix = `${Date.now()}-${randomUUID()}`;
132
+ const recoveryEmail = `recovery-concurrent-lock-${suffix}@example.com`;
133
+ const loginEmail = `old-concurrent-lock-${suffix}@example.edu`;
134
+ const { learnCard } = await getUser(`0${'f'.repeat(63)}`);
135
+ const collection = getUserKeysCollection();
136
+
137
+ await collection.insertOne(
138
+ makeRecoverableUser(loginEmail, recoveryEmail, `old-${suffix}`, learnCard.id.did())
139
+ );
140
+
141
+ try {
142
+ await startAndReadCode(recoveryEmail);
143
+
144
+ const guesses = await Promise.allSettled(
145
+ Array.from({ length: 10 }, () =>
146
+ getClient().keys.verifyRecoverySession({
147
+ email: recoveryEmail,
148
+ code: '000000',
149
+ })
150
+ )
151
+ );
152
+ const errorCodes = guesses.map(result =>
153
+ result.status === 'rejected'
154
+ ? (result.reason as { code?: string }).code
155
+ : 'fulfilled'
156
+ );
157
+ const attempts = Number(
158
+ await cache.node.get(getRecoveryOtpAttemptsCacheKey(recoveryEmail))
159
+ );
160
+
161
+ expect(errorCodes).not.toContain('fulfilled');
162
+ expect(errorCodes).toContain('TOO_MANY_REQUESTS');
163
+ expect(attempts).toBe(MAX_RECOVERY_OTP_ATTEMPTS);
164
+ expect(attempts).toBeLessThanOrEqual(MAX_RECOVERY_OTP_ATTEMPTS);
165
+ await expect(cache.node.get(getRecoveryOtpLockCacheKey(recoveryEmail))).resolves.toBe(
166
+ 'locked'
167
+ );
168
+ await expect(cache.node.get(getRecoveryOtpCacheKey(recoveryEmail))).resolves.toBeNull();
169
+ } finally {
170
+ vi.restoreAllMocks();
171
+ await collection.deleteMany({ recoveryEmail });
172
+ await cache.node.flushall();
173
+ }
174
+ });
175
+
176
+ it('does not count or lock incorrect guesses when no OTP exists', async () => {
177
+ const recoveryEmail = `recovery-no-otp-${Date.now()}-${randomUUID()}@example.com`;
178
+ const guesses = await Promise.allSettled(
179
+ Array.from({ length: 10 }, () =>
180
+ getClient().keys.verifyRecoverySession({
181
+ email: recoveryEmail,
182
+ code: '000000',
183
+ })
184
+ )
185
+ );
186
+
187
+ expect(
188
+ guesses.every(
189
+ result =>
190
+ result.status === 'rejected' &&
191
+ (result.reason as { code?: string }).code === 'BAD_REQUEST'
192
+ )
193
+ ).toBe(true);
194
+ await expect(
195
+ cache.node.get(getRecoveryOtpAttemptsCacheKey(recoveryEmail))
196
+ ).resolves.toBeNull();
197
+ await expect(cache.node.get(getRecoveryOtpLockCacheKey(recoveryEmail))).resolves.toBeNull();
198
+ });
199
+
200
+ it('enforces token expiry, one-use replay protection, and route scope', async () => {
201
+ const suffix = `${Date.now()}-${randomUUID()}`;
202
+ const recoveryEmail = `recovery-token-${suffix}@example.com`;
203
+ const loginEmail = `old-token-${suffix}@example.edu`;
204
+ const { learnCard } = await getUser('c'.repeat(64));
205
+ const collection = getUserKeysCollection();
206
+
207
+ await collection.insertOne(
208
+ makeRecoverableUser(loginEmail, recoveryEmail, `old-${suffix}`, learnCard.id.did())
209
+ );
210
+
211
+ try {
212
+ const code = await startAndReadCode(recoveryEmail);
213
+ const verified = await getClient().keys.verifyRecoverySession({
214
+ email: recoveryEmail,
215
+ code,
216
+ });
217
+
218
+ await expect(
219
+ getClient({
220
+ did: learnCard.id.did(),
221
+ isChallengeValid: true,
222
+ }).keys.completeRecoveryRebind({
223
+ recoverySessionToken: verified.recoverySessionToken,
224
+ newAuthToken: makeMockToken(`new-${suffix}@example.com`, `new-${suffix}`),
225
+ providerType: 'firebase',
226
+ primaryDid: learnCard.id.did(),
227
+ authShare: { encryptedData: 'new-share', encryptedDek: '', iv: '' },
228
+ })
229
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
230
+ await expect(
231
+ getClient().keys.useRecoverySession({
232
+ recoverySessionToken: verified.recoverySessionToken,
233
+ type: 'phrase',
234
+ })
235
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
236
+ } finally {
237
+ vi.restoreAllMocks();
238
+ await collection.deleteMany({ recoveryEmail });
239
+ await cache.node.flushall();
240
+ }
241
+ });
242
+
243
+ it('rebinds a new UID, purges history, and invalidates old recovery methods', async () => {
244
+ const suffix = `${Date.now()}-${randomUUID()}`;
245
+ const recoveryEmail = `recovery-happy-${suffix}@example.com`;
246
+ const oldLoginEmail = `old-happy-${suffix}@example.edu`;
247
+ const newLoginEmail = `new-happy-${suffix}@example.com`;
248
+ const oldUid = `old-${suffix}`;
249
+ const newUid = `new-${suffix}`;
250
+ const { learnCard } = await getUser('d'.repeat(64));
251
+ const collection = getUserKeysCollection();
252
+
253
+ await collection.insertOne(
254
+ makeRecoverableUser(oldLoginEmail, recoveryEmail, oldUid, learnCard.id.did())
255
+ );
256
+
257
+ try {
258
+ const code = await startAndReadCode(recoveryEmail);
259
+ const verified = await getClient().keys.verifyRecoverySession({
260
+ email: recoveryEmail,
261
+ code,
262
+ });
263
+ const recovered = await getClient().keys.useRecoverySession({
264
+ recoverySessionToken: verified.recoverySessionToken,
265
+ type: 'phrase',
266
+ });
267
+
268
+ expect(recovered.authShare.encryptedData).toBe('version-one-auth-share');
269
+ expect(recovered.shareVersion).toBe(1);
270
+ await expect(
271
+ getClient().keys.useRecoverySession({
272
+ recoverySessionToken: verified.recoverySessionToken,
273
+ type: 'phrase',
274
+ })
275
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
276
+
277
+ const result = await getClient({
278
+ did: learnCard.id.did(),
279
+ isChallengeValid: true,
280
+ }).keys.completeRecoveryRebind({
281
+ recoverySessionToken: recovered.rebindSessionToken,
282
+ newAuthToken: makeMockToken(newLoginEmail, newUid),
283
+ providerType: 'firebase',
284
+ primaryDid: learnCard.id.did(),
285
+ authShare: { encryptedData: 'rotated-auth-share', encryptedDek: '', iv: '' },
286
+ });
287
+
288
+ expect(result.shareVersion).toBe(3);
289
+ expect(result.recoveryMethodsRequireConfirmation).toEqual(['phrase']);
290
+
291
+ const stored = await collection.findOne({ primaryDid: learnCard.id.did() });
292
+ expect(stored?.authProviders).toContainEqual({ type: 'firebase', id: newUid });
293
+ expect(stored?.authProviders).not.toContainEqual({ type: 'firebase', id: oldUid });
294
+ expect(stored?.previousAuthShares).toEqual([]);
295
+ expect(stored?.recoveryMethods[0]).toMatchObject({
296
+ type: 'phrase',
297
+ shareVersion: 3,
298
+ confirmationStatus: 'pending',
299
+ });
300
+ expect(stored?.recoveryMethods[0]?.confirmedAt).toBeUndefined();
301
+
302
+ const oldStatus = await getClient().keys.getAuthShare({
303
+ authToken: makeMockToken(oldLoginEmail, oldUid),
304
+ providerType: 'firebase',
305
+ });
306
+ const newStatus = await getClient().keys.getAuthShare({
307
+ authToken: makeMockToken(newLoginEmail, newUid),
308
+ providerType: 'firebase',
309
+ });
310
+
311
+ expect(oldStatus).toBeNull();
312
+ expect(newStatus?.authShare?.encryptedData).toBe('rotated-auth-share');
313
+ expect(newStatus?.recoveryMethods).toEqual([]);
314
+ expect(newStatus?.sssActivationState).toBe('provisional');
315
+ expect(stored?.keyProvider).toBe('sss');
316
+ expect(stored?.provisionalCreatedAt).toBeUndefined();
317
+
318
+ // Even after the migration TTL, a rebound SSS account must retain its key.
319
+ const afterTtl = new Date(Date.now() + 31 * 24 * 60 * 60 * 1000);
320
+ await expect(
321
+ purgeExpiredProvisionalMigrationByAuthProvider(
322
+ { type: 'firebase', id: newUid },
323
+ afterTtl
324
+ )
325
+ ).resolves.toBe(false);
326
+ const afterCleanup = await collection.findOne({ primaryDid: learnCard.id.did() });
327
+ expect(afterCleanup?.authShare).toEqual(stored?.authShare);
328
+ expect(afterCleanup?.sssActivationState).toBe('provisional');
329
+
330
+ const caller = getClient({ did: learnCard.id.did(), isChallengeValid: true });
331
+ const auth = {
332
+ authToken: makeMockToken(newLoginEmail, newUid),
333
+ providerType: 'firebase' as const,
334
+ };
335
+ await expect(caller.keys.activate(auth)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
336
+ await caller.keys.addRecoveryMethod({ ...auth, type: 'phrase', shareVersion: 3 });
337
+ await expect(caller.keys.activate(auth)).rejects.toMatchObject({ code: 'BAD_REQUEST' });
338
+ await caller.keys.confirmRecoveryMethod({ ...auth, type: 'phrase' });
339
+ await expect(caller.keys.activate(auth)).resolves.toEqual({ success: true });
340
+ const activated = await collection.findOne({ primaryDid: learnCard.id.did() });
341
+ expect(activated?.sssActivationState).toBe('active');
342
+ expect(activated?.recoveryMethods[0]).toMatchObject({
343
+ shareVersion: 3,
344
+ confirmationStatus: 'confirmed',
345
+ confirmedAt: expect.any(Date),
346
+ });
347
+ } finally {
348
+ vi.restoreAllMocks();
349
+ await collection.deleteMany({ recoveryEmail });
350
+ await cache.node.flushall();
351
+ }
352
+ });
353
+
354
+ it('removes the exact old mapping during a cross-provider rebind', async () => {
355
+ const suffix = `${Date.now()}-${randomUUID()}`;
356
+ const recoveryEmail = `recovery-cross-provider-${suffix}@example.com`;
357
+ const oldLoginEmail = `old-cross-provider-${suffix}@example.edu`;
358
+ const newLoginEmail = `new-cross-provider-${suffix}@example.com`;
359
+ const oldProviderId = `oidc-${suffix}`;
360
+ const newProviderId = `firebase-${suffix}`;
361
+ const { learnCard } = await getUser('2'.repeat(64));
362
+ const collection = getUserKeysCollection();
363
+ const userKey = makeRecoverableUser(
364
+ oldLoginEmail,
365
+ recoveryEmail,
366
+ oldProviderId,
367
+ learnCard.id.did()
368
+ );
369
+
370
+ userKey.authProviders = [
371
+ { type: 'oidc', id: oldProviderId },
372
+ { type: 'firebase', id: `preserved-${suffix}` },
373
+ ];
374
+ await collection.insertOne(userKey);
375
+
376
+ try {
377
+ const code = await startAndReadCode(recoveryEmail);
378
+ const verified = await getClient().keys.verifyRecoverySession({
379
+ email: recoveryEmail,
380
+ code,
381
+ });
382
+ const recovered = await getClient().keys.useRecoverySession({
383
+ recoverySessionToken: verified.recoverySessionToken,
384
+ type: 'phrase',
385
+ });
386
+
387
+ await getClient({
388
+ did: learnCard.id.did(),
389
+ isChallengeValid: true,
390
+ }).keys.completeRecoveryRebind({
391
+ recoverySessionToken: recovered.rebindSessionToken,
392
+ newAuthToken: makeMockToken(newLoginEmail, newProviderId),
393
+ providerType: 'firebase',
394
+ primaryDid: learnCard.id.did(),
395
+ authShare: { encryptedData: 'cross-provider-share', encryptedDek: '', iv: '' },
396
+ });
397
+
398
+ const stored = await collection.findOne({ primaryDid: learnCard.id.did() });
399
+
400
+ expect(stored?.authProviders).not.toContainEqual({
401
+ type: 'oidc',
402
+ id: oldProviderId,
403
+ });
404
+ expect(stored?.authProviders).toContainEqual({
405
+ type: 'firebase',
406
+ id: newProviderId,
407
+ });
408
+ expect(stored?.authProviders).toContainEqual({
409
+ type: 'firebase',
410
+ id: `preserved-${suffix}`,
411
+ });
412
+ } finally {
413
+ vi.restoreAllMocks();
414
+ await collection.deleteMany({ recoveryEmail });
415
+ await cache.node.flushall();
416
+ }
417
+ });
418
+
419
+ it('rejects an expired recovery token and a recovery token used as provider auth', async () => {
420
+ const suffix = `${Date.now()}-${randomUUID()}`;
421
+ const recoveryEmail = `recovery-expiry-${suffix}@example.com`;
422
+ const loginEmail = `old-expiry-${suffix}@example.edu`;
423
+ const { learnCard } = await getUser('e'.repeat(64));
424
+ const collection = getUserKeysCollection();
425
+
426
+ await collection.insertOne(
427
+ makeRecoverableUser(loginEmail, recoveryEmail, `old-${suffix}`, learnCard.id.did())
428
+ );
429
+
430
+ try {
431
+ const code = await startAndReadCode(recoveryEmail);
432
+ const verified = await getClient().keys.verifyRecoverySession({
433
+ email: recoveryEmail,
434
+ code,
435
+ });
436
+
437
+ await cache.delete([getRecoverySessionCacheKey(verified.recoverySessionToken)]);
438
+ await expect(
439
+ getClient().keys.useRecoverySession({
440
+ recoverySessionToken: verified.recoverySessionToken,
441
+ type: 'phrase',
442
+ })
443
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
444
+ await expect(
445
+ getClient().keys.getAuthShare({
446
+ authToken: verified.recoverySessionToken,
447
+ providerType: 'firebase',
448
+ })
449
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
450
+ } finally {
451
+ vi.restoreAllMocks();
452
+ await collection.deleteMany({ recoveryEmail });
453
+ await cache.node.flushall();
454
+ }
455
+ });
456
+ });