@learncard/lca-api-service 1.4.0 → 1.5.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 (191) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +52 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/bundle.mjs +9 -1
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/app.d.ts +67 -3
  13. package/dist/cache/getDel.d.ts +2 -0
  14. package/dist/cache/getDel.test.d.ts +1 -0
  15. package/dist/cache/login-tickets.d.ts +12 -0
  16. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  17. package/dist/config/cacheEnvironment.d.ts +16 -0
  18. package/dist/config/environment.d.ts +101 -0
  19. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  20. package/dist/config/escrowDeployment.test.d.ts +1 -0
  21. package/dist/config/oidcEnvironment.d.ts +74 -0
  22. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  23. package/dist/config/stageConfig.d.ts +62 -0
  24. package/dist/config/stageConfig.test.d.ts +1 -0
  25. package/dist/didkit_wasm_bg.wasm +0 -0
  26. package/dist/docker-entry.d.ts +1 -1
  27. package/dist/helpers/auth.helpers.d.ts +1 -1
  28. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  29. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  30. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  31. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  32. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  33. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  34. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  35. package/dist/helpers/oidc.helpers.d.ts +47 -0
  36. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  37. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  38. package/dist/helpers/social-token.helpers.d.ts +19 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +2 -2
  41. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  42. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  43. package/dist/models/AuthSubject.d.ts +20 -0
  44. package/dist/models/EscrowHold.d.ts +79 -1
  45. package/dist/models/Notification.d.ts +5 -0
  46. package/dist/models/UserKey.d.ts +11 -1
  47. package/dist/models/index.d.ts +1 -0
  48. package/dist/oidc.d.ts +5 -0
  49. package/dist/publish-metafile.json +1 -0
  50. package/dist/routes/auth.d.ts +31 -0
  51. package/dist/routes/escrow.d.ts +18 -4
  52. package/dist/routes/keys.d.ts +1 -0
  53. package/dist/routes/notifications.d.ts +5 -0
  54. package/dist/routes/signing-authority.d.ts +14 -0
  55. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  56. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  57. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  58. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  59. package/dist/services/escrow-enclave/index.d.ts +16 -0
  60. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  61. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  65. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  66. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  67. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  68. package/dist/services/escrow-enclave/types.d.ts +52 -8
  69. package/dist/types/notifications.d.ts +5 -0
  70. package/esbuildPlugins.cjs +1 -0
  71. package/lambda.ts +27 -117
  72. package/lambdaApp.ts +146 -0
  73. package/oidcLambda.test.ts +38 -0
  74. package/oidcLambda.ts +18 -0
  75. package/oidcLambdaApp.ts +11 -0
  76. package/package.json +22 -13
  77. package/project.json +8 -1
  78. package/scripts/README.md +88 -0
  79. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  80. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  81. package/scripts/escrow-blob-mode-report.ts +465 -0
  82. package/scripts/keycloak-admin.ts +108 -0
  83. package/scripts/keycloak-provider-links.ts +147 -0
  84. package/scripts/print-seed-rollout-flags.ts +21 -0
  85. package/scripts/provision-keycloak-users.ts +419 -0
  86. package/seedMigrationApp.test.ts +145 -0
  87. package/seedMigrationApp.ts +50 -0
  88. package/seedMigrationLambda.test.ts +30 -135
  89. package/seedMigrationLambda.ts +15 -46
  90. package/serverless-local.yml +25 -1
  91. package/serverless.function-env.cjs +58 -0
  92. package/serverless.yml +174 -37
  93. package/src/accesslayer/notifications/index.ts +9 -2
  94. package/src/accesslayer/signing-authority/index.ts +4 -1
  95. package/src/app.ts +2 -0
  96. package/src/cache/getDel.test.ts +34 -0
  97. package/src/cache/getDel.ts +32 -0
  98. package/src/cache/index.ts +1 -1
  99. package/src/cache/login-tickets.ts +26 -0
  100. package/src/config/applyDockerStageConfig.ts +6 -0
  101. package/src/config/cacheEnvironment.ts +34 -0
  102. package/src/config/environment.test.ts +45 -2
  103. package/src/config/environment.ts +90 -23
  104. package/src/config/escrowBundleMode.test.ts +52 -0
  105. package/src/config/escrowDeployment.test.ts +114 -0
  106. package/src/config/oidcEnvironment.ts +59 -0
  107. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  108. package/src/config/stageConfig.test.ts +26 -0
  109. package/src/config/stageConfig.ts +24 -0
  110. package/src/docker-entry.ts +9 -0
  111. package/src/helpers/auth.helpers.ts +68 -30
  112. package/src/helpers/escrowCancelLink.test.ts +60 -0
  113. package/src/helpers/escrowCancelLink.ts +38 -0
  114. package/src/helpers/escrowCancelToken.test.ts +105 -0
  115. package/src/helpers/escrowCancelToken.ts +47 -0
  116. package/src/helpers/keycloak.helpers.ts +120 -0
  117. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  118. package/src/helpers/login-ticket.helpers.ts +91 -0
  119. package/src/helpers/oidc.helpers.ts +354 -0
  120. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  121. package/src/helpers/rate-limit.helpers.ts +73 -0
  122. package/src/helpers/social-token.helpers.ts +144 -0
  123. package/src/index.ts +1 -0
  124. package/src/jobs/escrowBlobRewrap.ts +121 -0
  125. package/src/jobs/escrowHoldReminders.ts +177 -0
  126. package/src/models/AuthSubject.ts +147 -0
  127. package/src/models/EscrowHold.ts +321 -5
  128. package/src/models/UserKey.ts +91 -6
  129. package/src/models/index.ts +1 -0
  130. package/src/oidc.ts +371 -0
  131. package/src/routes/auth.ts +173 -0
  132. package/src/routes/escrow.ts +281 -34
  133. package/src/routes/keys.ts +5 -0
  134. package/src/routes/signing-authority.ts +39 -1
  135. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  136. package/src/services/escrow-enclave/README.md +81 -10
  137. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  141. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  142. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  143. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  144. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  145. package/src/services/escrow-enclave/cancellation.ts +59 -0
  146. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  147. package/src/services/escrow-enclave/index.ts +145 -7
  148. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  149. package/src/services/escrow-enclave/notifications.ts +340 -6
  150. package/src/services/escrow-enclave/production.test.ts +34 -0
  151. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  152. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  153. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  154. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  155. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  156. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  157. package/src/services/escrow-enclave/types.ts +63 -17
  158. package/test/auth-tickets.spec.ts +480 -0
  159. package/test/helpers/keycloak-broker.ts +181 -0
  160. package/test/helpers/live-mongo.ts +9 -0
  161. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  162. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  163. package/test/keycloak-admin.spec.ts +58 -0
  164. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  165. package/test/keycloak-migration.integration.spec.ts +266 -0
  166. package/test/keycloak-provider-links.spec.ts +201 -0
  167. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  168. package/test/keycloak-verify.integration.spec.ts +100 -0
  169. package/test/keycloak-verify.spec.ts +307 -0
  170. package/test/keys-escrow.spec.ts +963 -24
  171. package/test/models/authSubjectIndexes.spec.ts +132 -0
  172. package/test/models/escrow.spec.ts +375 -13
  173. package/test/notifications.spec.ts +4 -0
  174. package/test/oidc-e2e/broker.spec.ts +116 -0
  175. package/test/oidc-e2e/compose.yaml +33 -0
  176. package/test/oidc-e2e/fixtures.ts +245 -0
  177. package/test/oidc-e2e/lock.test.mjs +73 -0
  178. package/test/oidc-e2e/playwright.config.ts +21 -0
  179. package/test/oidc-e2e/run.mjs +312 -0
  180. package/test/oidc-e2e/tsconfig.json +10 -0
  181. package/test/oidc-e2e/types.ts +21 -0
  182. package/test/oidc.integration.spec.ts +54 -0
  183. package/test/oidc.spec.ts +697 -0
  184. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  185. package/test/provision-keycloak-users.spec.ts +299 -0
  186. package/test/signing-authority.spec.ts +50 -0
  187. package/tsconfig.build.json +1 -1
  188. package/tsconfig.json +1 -0
  189. package/vitest.config.ts +24 -1
  190. package/vitest.integration.config.ts +20 -3
  191. package/serverless.escrow-env.cjs +0 -20
@@ -0,0 +1,266 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { ObjectId, type Collection } from 'mongodb';
4
+ import { promisify } from 'node:util';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
7
+ import { z } from 'zod';
8
+ import { createKeycloakAdmin } from '../scripts/keycloak-admin';
9
+ import { encryptAuthShare } from '../src/helpers/shareEncryption.helpers';
10
+ import { maskEmail } from '../src/helpers/maskEmail';
11
+ import { roundtripEnabled, signInThroughBroker, trpc } from './helpers/keycloak-broker';
12
+
13
+ const exec = promisify(execFile);
14
+ const runProvision = async (...args: string[]): Promise<string> => {
15
+ const { stdout } = await exec(
16
+ 'bun',
17
+ [
18
+ '--conditions=development',
19
+ 'scripts/provision-keycloak-users.ts',
20
+ '--no-link-providers',
21
+ ...args,
22
+ ],
23
+ {
24
+ cwd: fileURLToPath(new URL('../', import.meta.url)),
25
+ env: { ...process.env, NODE_ENV: 'development' },
26
+ timeout: 60_000,
27
+ }
28
+ );
29
+ process.stdout.write(stdout);
30
+ return stdout;
31
+ };
32
+
33
+ describe.runIf(roundtripEnabled)('Firebase-era UserKey migration', () => {
34
+ let keys: typeof import('../src/models/UserKey');
35
+ let subjects: typeof import('../src/models/AuthSubject');
36
+ let mongo: typeof import('./helpers/live-mongo');
37
+ let admin: Awaited<ReturnType<typeof createKeycloakAdmin>>;
38
+ const emails: string[] = [];
39
+ const ids: ObjectId[] = [];
40
+ // Match the real driver's ObjectIds; the model type describes serialized records.
41
+ const userKeys = (): Collection<
42
+ Omit<import('../src/models/UserKey').MongoUserKeyType, '_id'>
43
+ > =>
44
+ keys.getUserKeysCollection() as unknown as Collection<
45
+ Omit<import('../src/models/UserKey').MongoUserKeyType, '_id'>
46
+ >;
47
+ const knownShare = { encryptedData: 'known-firebase-era-auth-share', encryptedDek: '', iv: '' };
48
+
49
+ beforeAll(async () => {
50
+ if (!process.env.SEED) throw new Error('Set SEED to the running API encryption seed');
51
+ keys = await import('../src/models/UserKey');
52
+ subjects = await import('../src/models/AuthSubject');
53
+ mongo = await import('./helpers/live-mongo');
54
+ admin = await createKeycloakAdmin();
55
+ });
56
+ afterAll(async () => {
57
+ if (!mongo) return;
58
+ try {
59
+ for (const email of emails) {
60
+ for (const user of await admin.findUsers(email))
61
+ await admin.request(`/users/${user.id}`, { method: 'DELETE' });
62
+ await subjects
63
+ .getAuthSubjectsCollection()
64
+ .deleteOne({ identityKey: `email:${email}` });
65
+ }
66
+ await userKeys().deleteMany({ _id: { $in: ids } });
67
+ } finally {
68
+ await mongo.client.close();
69
+ }
70
+ });
71
+
72
+ const seedKey = async (
73
+ phoneOnly = false
74
+ ): Promise<
75
+ Omit<import('../src/models/UserKey').MongoUserKeyType, '_id'> & { _id: ObjectId }
76
+ > => {
77
+ const id = new ObjectId();
78
+ const email = `migration-${id}@example.com`;
79
+ ids.push(id);
80
+ if (!phoneOnly) emails.push(email);
81
+ const key = keys.MongoUserKeyValidator.parse({
82
+ contactMethod: {
83
+ type: phoneOnly ? 'phone' : 'email',
84
+ value: phoneOnly ? `+1555${Date.now().toString().slice(-7)}` : email,
85
+ },
86
+ authProviders: [{ type: 'firebase', id: `firebase-uid-${id}` }],
87
+ primaryDid: `did:example:firebase-${id}`,
88
+ keyProvider: 'sss',
89
+ sssActivationState: 'active',
90
+ authShare: encryptAuthShare(knownShare, process.env.SEED!),
91
+ shareVersion: 1,
92
+ createdAt: new Date(),
93
+ updatedAt: new Date(),
94
+ });
95
+ const record = { ...key, _id: id };
96
+ await userKeys().insertOne(record);
97
+ return record;
98
+ };
99
+ const snapshot = async (email: string): Promise<unknown> => {
100
+ const users = await admin.findUsers(email);
101
+ return {
102
+ keys: await userKeys().find({ 'contactMethod.value': email }).toArray(),
103
+ subjects: await subjects
104
+ .getAuthSubjectsCollection()
105
+ .find({ identityKey: `email:${email}` })
106
+ .toArray(),
107
+ users,
108
+ links: await Promise.all(users.map(user => admin.links(user.id))),
109
+ };
110
+ };
111
+
112
+ it('returns null without mapping, then the exact original share and DID after provisioning', async () => {
113
+ const seeded = await seedKey();
114
+ const email = seeded.contactMethod.value;
115
+ const before = await snapshot(email);
116
+ expect(await runProvision('--email', email)).toContain('DRY RUN');
117
+ expect(await snapshot(email)).toEqual(before); // Includes absent subject/user: no accidental upserts.
118
+
119
+ const { verifyKeycloakToken } = await import('../src/helpers/auth.helpers');
120
+ const negative = await signInThroughBroker(email);
121
+ const unlinked = await verifyKeycloakToken(negative.idToken);
122
+ expect(unlinked).toMatchObject({ email, providerType: 'keycloak' });
123
+ expect(await keys.findUserKeyByAuthProvider('keycloak', unlinked.id)).toBeNull();
124
+ expect(
125
+ await trpc('keys.getAuthShare', {
126
+ authToken: negative.idToken,
127
+ providerType: 'keycloak',
128
+ })
129
+ ).toBeNull();
130
+
131
+ const beforeExisting = await snapshot(email);
132
+ await runProvision('--email', email);
133
+ expect(await snapshot(email)).toEqual(beforeExisting);
134
+ await runProvision('--apply', '--email', email);
135
+
136
+ const positive = await signInThroughBroker(email);
137
+ const linked = await verifyKeycloakToken(positive.idToken);
138
+ expect(linked.id).toBe(unlinked.id);
139
+ const restored = await keys.findUserKeyByAuthProvider('keycloak', linked.id);
140
+ expect(restored).toMatchObject({
141
+ _id: seeded._id,
142
+ authShare: seeded.authShare,
143
+ primaryDid: seeded.primaryDid,
144
+ shareVersion: 1,
145
+ });
146
+ expect(restored!.authProviders).toEqual([
147
+ ...seeded.authProviders,
148
+ { type: 'keycloak', id: linked.id },
149
+ ]);
150
+ expect(
151
+ await keys.getUserKeysCollection().countDocuments({ 'contactMethod.value': email })
152
+ ).toBe(1);
153
+ expect(
154
+ await trpc('keys.getAuthShare', {
155
+ authToken: positive.idToken,
156
+ providerType: 'keycloak',
157
+ })
158
+ ).toMatchObject({
159
+ authShare: knownShare,
160
+ primaryDid: seeded.primaryDid,
161
+ shareVersion: 1,
162
+ keyProvider: 'sss',
163
+ sssActivationState: 'active',
164
+ });
165
+ const subject = await subjects
166
+ .getAuthSubjectsCollection()
167
+ .findOne({ identityKey: `email:${email}` });
168
+ expect(await admin.links(linked.id)).toContainEqual(
169
+ expect.objectContaining({
170
+ identityProvider: 'lca-api',
171
+ userId: subject!.subject,
172
+ })
173
+ );
174
+ expect(positive.trace.every(hop => /^3\d\d /.test(hop))).toBe(true);
175
+ const after = await snapshot(email);
176
+ expect(await runProvision('--apply', '--email', email)).toContain('unchanged');
177
+ expect(await snapshot(email)).toEqual(after);
178
+ process.stdout.write(
179
+ `Migration proof ${maskEmail(email)}: negative HTTP=null; positive _id=${seeded._id}, primaryDid=${seeded.primaryDid}, shareVersion=1, encrypted share unchanged, returned share=known fixture, email record count=1; dry-run snapshots identical (absent and existing identities); second apply identical.\n`
180
+ );
181
+ }, 120_000);
182
+
183
+ it('pre-provisions a missing user and federated link before its first silent login', async () => {
184
+ const seeded = await seedKey();
185
+ const email = seeded.contactMethod.value;
186
+ expect(await admin.findUsers(email)).toHaveLength(0);
187
+ await runProvision('--apply', '--email', email);
188
+ const { idToken } = await signInThroughBroker(email);
189
+ expect(
190
+ await trpc('keys.getAuthShare', { authToken: idToken, providerType: 'keycloak' })
191
+ ).toMatchObject({ authShare: knownShare, primaryDid: seeded.primaryDid });
192
+ }, 60_000);
193
+
194
+ it('refuses a different lca-api link without changing either database resource', async () => {
195
+ const seeded = await seedKey();
196
+ const email = seeded.contactMethod.value;
197
+ await subjects.getOrCreateAuthSubject(`email:${email}`, { email, emailVerified: true });
198
+ await admin.request('/users', {
199
+ method: 'POST',
200
+ body: JSON.stringify({ username: email, email, emailVerified: true, enabled: true }),
201
+ });
202
+ const [user] = await admin.findUsers(email);
203
+ await admin.request(`/users/${user!.id}/federated-identity/lca-api`, {
204
+ method: 'POST',
205
+ body: JSON.stringify({
206
+ identityProvider: 'lca-api',
207
+ userId: randomUUID(),
208
+ userName: 'different-subject',
209
+ }),
210
+ });
211
+ const before = await snapshot(email);
212
+ await expect(runProvision('--apply', '--email', email)).rejects.toMatchObject({
213
+ code: 1,
214
+ stdout: expect.stringContaining('REFUSED: existing lca-api link differs'),
215
+ });
216
+ expect(await snapshot(email)).toEqual(before);
217
+ }, 60_000);
218
+
219
+ it('counts and skips phone-only records without writing', async () => {
220
+ const seeded = await seedKey(true);
221
+ const phoneCount = await keys
222
+ .getUserKeysCollection()
223
+ .countDocuments({ 'authProviders.type': 'firebase', 'contactMethod.type': 'phone' });
224
+ const output = await runProvision().catch((error: unknown): string => {
225
+ // A full dry-run can also report unrelated, deliberately conflicting fixtures.
226
+ const failure = z
227
+ .object({ code: z.literal(1), stdout: z.string().includes('Summary:') })
228
+ .parse(error);
229
+ return failure.stdout;
230
+ });
231
+ const serialized = output.match(/^Summary: (.+)$/m)?.[1];
232
+ const summary = z.object({ skipped: z.number() }).parse(JSON.parse(serialized!));
233
+ expect(summary.skipped).toBe(phoneCount);
234
+ expect(phoneCount).toBeGreaterThanOrEqual(1);
235
+ expect(await userKeys().findOne({ _id: seeded._id })).toEqual(seeded);
236
+ process.stdout.write(
237
+ `Phone-only skip count=${summary.skipped} (includes one synthetic fixture); no writes.\n`
238
+ );
239
+ }, 60_000);
240
+
241
+ it('lets a linked social identity share the provisioned subject in real Mongo', async () => {
242
+ await subjects.createAuthSubjectIndexes();
243
+ const email = `link-${randomUUID()}@example.com`;
244
+ const googleKey = `google:${randomUUID()}`;
245
+ const attrs = { email, emailVerified: true };
246
+
247
+ const emailSubject = await subjects.getOrCreateAuthSubject(`email:${email}`, attrs);
248
+ try {
249
+ const linked = await subjects.getOrCreateAuthSubjectLinkedTo(
250
+ googleKey,
251
+ emailSubject.subject,
252
+ attrs
253
+ );
254
+ expect(linked.subject).toBe(emailSubject.subject);
255
+ expect(
256
+ await subjects
257
+ .getAuthSubjectsCollection()
258
+ .countDocuments({ subject: emailSubject.subject })
259
+ ).toBe(2);
260
+ } finally {
261
+ await subjects
262
+ .getAuthSubjectsCollection()
263
+ .deleteMany({ identityKey: { $in: [`email:${email}`, googleKey] } });
264
+ }
265
+ }, 60_000);
266
+ });
@@ -0,0 +1,201 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ createKeycloakAdmin,
4
+ KeycloakAdminError,
5
+ type FederatedIdentity,
6
+ } from '../scripts/keycloak-admin';
7
+ import {
8
+ fetchFirebaseUsers,
9
+ linkFirebaseProviders,
10
+ type FirebaseProvider,
11
+ type FirebaseReader,
12
+ } from '../scripts/keycloak-provider-links';
13
+
14
+ const providers: FirebaseProvider[] = [
15
+ { providerId: 'google.com', uid: 'google-sub', email: 'person@example.com' },
16
+ { providerId: 'apple.com', uid: 'apple-sub' },
17
+ ];
18
+ const makeAdmin = (): {
19
+ links: ReturnType<typeof vi.fn<(id: string) => Promise<FederatedIdentity[]>>>;
20
+ request: ReturnType<typeof vi.fn<(path: string, init?: RequestInit) => Promise<Response>>>;
21
+ } => ({
22
+ links: vi.fn<(id: string) => Promise<FederatedIdentity[]>>().mockResolvedValue([]),
23
+ request: vi
24
+ .fn<(path: string, init?: RequestInit) => Promise<Response>>()
25
+ .mockResolvedValue(new Response(null, { status: 204 })),
26
+ });
27
+
28
+ afterEach((): void => vi.unstubAllEnvs());
29
+
30
+ describe('Firebase provider pre-linking', (): void => {
31
+ it('links Google and Apple with provider subjects and is idempotent on rerun', async (): Promise<void> => {
32
+ const admin = makeAdmin();
33
+ const input = { userId: 'user/id', providers, apply: true, admin, log: vi.fn() };
34
+ expect(await linkFirebaseProviders(input)).toMatchObject({ linked: 2, conflicts: 0 });
35
+ expect(
36
+ admin.request.mock.calls.map(([path, init]) => [path, JSON.parse(String(init?.body))])
37
+ ).toEqual([
38
+ [
39
+ '/users/user%2Fid/federated-identity/google',
40
+ {
41
+ identityProvider: 'google',
42
+ userId: 'google-sub',
43
+ userName: 'person@example.com',
44
+ },
45
+ ],
46
+ [
47
+ '/users/user%2Fid/federated-identity/apple',
48
+ { identityProvider: 'apple', userId: 'apple-sub', userName: 'apple-sub' },
49
+ ],
50
+ ]);
51
+ admin.links.mockResolvedValue([
52
+ { identityProvider: 'google', userId: 'google-sub', userName: 'old-name' },
53
+ { identityProvider: 'apple', userId: 'apple-sub', userName: 'apple-sub' },
54
+ ]);
55
+ expect(await linkFirebaseProviders(input)).toMatchObject({ linked: 0, alreadyLinked: 2 });
56
+ expect(admin.request).toHaveBeenCalledTimes(2);
57
+ });
58
+
59
+ it('skips occupied aliases without writes', async (): Promise<void> => {
60
+ const admin = makeAdmin();
61
+ admin.links.mockResolvedValue([
62
+ { identityProvider: 'google', userId: 'other', userName: 'other' },
63
+ ]);
64
+ const result = await linkFirebaseProviders({
65
+ userId: 'user',
66
+ providers: [providers[0]!],
67
+ apply: true,
68
+ admin,
69
+ log: vi.fn(),
70
+ });
71
+ expect(result.conflicts).toBe(1);
72
+ expect(admin.request).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it('reports a link belonging to another Keycloak user as a conflict and continues', async (): Promise<void> => {
76
+ const admin = makeAdmin();
77
+ admin.request.mockRejectedValueOnce(new KeycloakAdminError(409));
78
+ const result = await linkFirebaseProviders({
79
+ userId: 'user',
80
+ providers,
81
+ apply: true,
82
+ admin,
83
+ log: vi.fn(),
84
+ });
85
+ expect(result).toMatchObject({ conflicts: 1, linked: 1 });
86
+ expect(admin.request.mock.calls.every(([, init]) => init?.method === 'POST')).toBe(true);
87
+ });
88
+
89
+ it('dry-run without a user makes no Keycloak calls and prints both plans', async (): Promise<void> => {
90
+ const admin = makeAdmin();
91
+ const log = vi.fn();
92
+ const result = await linkFirebaseProviders({ providers, apply: false, admin, log });
93
+ expect(result).toMatchObject({ linked: 0, wouldLink: 2 });
94
+ expect(admin.links).not.toHaveBeenCalled();
95
+ expect(admin.request).not.toHaveBeenCalled();
96
+ expect(log).toHaveBeenCalledTimes(2);
97
+ });
98
+
99
+ it('dry-run of a mapped user only reads existing links', async (): Promise<void> => {
100
+ const admin = makeAdmin();
101
+ expect(
102
+ await linkFirebaseProviders({
103
+ userId: 'user',
104
+ providers,
105
+ apply: false,
106
+ admin,
107
+ log: vi.fn(),
108
+ })
109
+ ).toMatchObject({ wouldLink: 2 });
110
+ expect(admin.links).toHaveBeenCalledWith('user');
111
+ expect(admin.request).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('counts non-social users and refuses ambiguous provider subjects', async (): Promise<void> => {
115
+ const admin = makeAdmin();
116
+ expect(
117
+ await linkFirebaseProviders({
118
+ providers: [{ providerId: 'password', uid: 'firebase' }],
119
+ apply: true,
120
+ admin,
121
+ log: vi.fn(),
122
+ })
123
+ ).toMatchObject({ noSocialProvider: 1 });
124
+ expect(
125
+ await linkFirebaseProviders({
126
+ userId: 'user',
127
+ providers: [providers[0]!, { providerId: 'google.com', uid: 'different' }],
128
+ apply: true,
129
+ admin,
130
+ log: vi.fn(),
131
+ })
132
+ ).toMatchObject({ conflicts: 1 });
133
+ expect(admin.request).not.toHaveBeenCalled();
134
+ });
135
+
136
+ it('uses the refresh-once path for a 401 during the linking POST', async (): Promise<void> => {
137
+ vi.stubEnv('KEYCLOAK_ADMIN_CLIENT_SECRET', 'test');
138
+ const fetchRequest = vi
139
+ .fn<typeof fetch>()
140
+ .mockResolvedValueOnce(Response.json({ access_token: 'first', expires_in: 100 }))
141
+ .mockResolvedValueOnce(Response.json([]))
142
+ .mockResolvedValueOnce(new Response(null, { status: 401 }))
143
+ .mockResolvedValueOnce(Response.json({ access_token: 'second', expires_in: 100 }))
144
+ .mockResolvedValueOnce(new Response(null, { status: 204 }));
145
+ const admin = await createKeycloakAdmin(fetchRequest);
146
+ expect(
147
+ await linkFirebaseProviders({
148
+ userId: 'user',
149
+ providers: [providers[0]!],
150
+ apply: true,
151
+ admin,
152
+ log: vi.fn(),
153
+ })
154
+ ).toMatchObject({ linked: 1 });
155
+ expect(fetchRequest).toHaveBeenCalledTimes(5);
156
+ expect(fetchRequest.mock.calls[4]?.[1]).toMatchObject({
157
+ method: 'POST',
158
+ headers: { Authorization: 'Bearer second' },
159
+ });
160
+ expect(fetchRequest.mock.calls[4]?.[1]?.body).toBe(fetchRequest.mock.calls[2]?.[1]?.body);
161
+ });
162
+
163
+ it('propagates non-conflict failures', async (): Promise<void> => {
164
+ const admin = makeAdmin();
165
+ admin.request.mockRejectedValueOnce(new KeycloakAdminError(403));
166
+ await expect(
167
+ linkFirebaseProviders({ userId: 'user', providers, apply: true, admin, log: vi.fn() })
168
+ ).rejects.toThrow('403');
169
+ });
170
+ });
171
+
172
+ describe('Firebase discovery', (): void => {
173
+ it('deduplicates UIDs and batches at 100 with a pause', async (): Promise<void> => {
174
+ const getUsers = vi
175
+ .fn<FirebaseReader['getUsers']>()
176
+ .mockImplementation(async identifiers => ({
177
+ users: identifiers.map(({ uid }) => ({
178
+ uid,
179
+ disabled: false,
180
+ emailVerified: true,
181
+ providerData: providers,
182
+ })),
183
+ }));
184
+ const sleep = vi.fn<(ms: number) => Promise<void>>().mockResolvedValue(undefined);
185
+ const ids = Array.from({ length: 101 }, (_, index) => `uid-${index}`);
186
+ const users = await fetchFirebaseUsers([...ids, ids[0]!], { getUsers }, sleep);
187
+ expect(users.size).toBe(101);
188
+ expect(getUsers.mock.calls.map(([batch]) => batch.length)).toEqual([100, 1]);
189
+ expect(sleep).toHaveBeenCalledExactlyOnceWith(1000);
190
+ expect(users.get('uid-0')?.providerData).toEqual(providers);
191
+ });
192
+
193
+ it('backs off on rate limits and stops after three retries', async (): Promise<void> => {
194
+ const error = { code: 'auth/too-many-requests' };
195
+ const getUsers = vi.fn<FirebaseReader['getUsers']>().mockRejectedValue(error);
196
+ const sleep = vi.fn<(ms: number) => Promise<void>>().mockResolvedValue(undefined);
197
+ await expect(fetchFirebaseUsers(['uid'], { getUsers }, sleep)).rejects.toEqual(error);
198
+ expect(getUsers).toHaveBeenCalledTimes(4);
199
+ expect(sleep.mock.calls).toEqual([[1000], [2000], [4000]]);
200
+ });
201
+ });