@learncard/lca-api-service 1.3.2 → 1.5.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 (240) hide show
  1. package/.env.example +99 -2
  2. package/AGENTS.md +155 -44
  3. package/CHANGELOG.md +84 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/compose.env +5 -0
  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/accesslayer/signing-authority/index.d.ts +8 -2
  13. package/dist/app.d.ts +361 -8
  14. package/dist/cache/challenges.d.ts +2 -0
  15. package/dist/cache/getDel.d.ts +2 -0
  16. package/dist/cache/getDel.test.d.ts +1 -0
  17. package/dist/cache/login-tickets.d.ts +12 -0
  18. package/dist/cache/recoverySessions.d.ts +42 -0
  19. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  20. package/dist/config/cacheEnvironment.d.ts +16 -0
  21. package/dist/config/environment.d.ts +253 -2
  22. package/dist/config/environment.test.d.ts +1 -0
  23. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  24. package/dist/config/escrowDeployment.test.d.ts +1 -0
  25. package/dist/config/oidcEnvironment.d.ts +74 -0
  26. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  27. package/dist/config/stageConfig.d.ts +62 -0
  28. package/dist/config/stageConfig.test.d.ts +1 -0
  29. package/dist/didkit_wasm_bg.wasm +0 -0
  30. package/dist/docker-entry.d.ts +1 -1
  31. package/dist/helpers/auth.helpers.d.ts +1 -1
  32. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  33. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  34. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  35. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  36. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  37. package/dist/helpers/learnCard.signingAuthority.test.d.ts +1 -0
  38. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  39. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  40. package/dist/helpers/oidc.helpers.d.ts +47 -0
  41. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  42. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  43. package/dist/helpers/rateLimit.helpers.d.ts +4 -0
  44. package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
  45. package/dist/helpers/seedEncryption.helpers.test.d.ts +1 -0
  46. package/dist/helpers/social-token.helpers.d.ts +19 -0
  47. package/dist/index.d.ts +1 -0
  48. package/dist/index.js +2 -2
  49. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  50. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  51. package/dist/migrations/signingAuthoritySeeds.d.ts +13 -0
  52. package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
  53. package/dist/models/AuthSubject.d.ts +20 -0
  54. package/dist/models/EscrowHold.d.ts +153 -0
  55. package/dist/models/Notification.d.ts +5 -0
  56. package/dist/models/SigningAuthority.d.ts +7 -1
  57. package/dist/models/UserKey.d.ts +326 -3
  58. package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
  59. package/dist/models/index.d.ts +2 -0
  60. package/dist/oidc.d.ts +5 -0
  61. package/dist/routes/auth.d.ts +31 -0
  62. package/dist/routes/escrow.d.ts +164 -0
  63. package/dist/routes/index.d.ts +6 -0
  64. package/dist/routes/keys.d.ts +182 -7
  65. package/dist/routes/notifications.d.ts +5 -0
  66. package/dist/routes/qr-login.d.ts +1 -1
  67. package/dist/routes/signing-authority.d.ts +14 -0
  68. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  69. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  70. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  71. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  72. package/dist/services/escrow-enclave/index.d.ts +25 -0
  73. package/dist/services/escrow-enclave/notifications.d.ts +24 -0
  74. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  75. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  76. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  77. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  78. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  79. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  80. package/dist/services/escrow-enclave/softwareEnclave.d.ts +27 -0
  81. package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
  82. package/dist/services/escrow-enclave/types.d.ts +111 -0
  83. package/dist/types/notifications.d.ts +5 -0
  84. package/dist/types/seed-encryption.d.ts +31 -0
  85. package/dist/types/seed-migration.d.ts +49 -0
  86. package/esbuildPlugins.cjs +1 -0
  87. package/lambda.ts +27 -112
  88. package/lambdaApp.ts +146 -0
  89. package/oidcLambda.test.ts +38 -0
  90. package/oidcLambda.ts +18 -0
  91. package/oidcLambdaApp.ts +11 -0
  92. package/package.json +30 -14
  93. package/scripts/README.md +88 -0
  94. package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
  95. package/scripts/check-signing-authority-redis.mjs +93 -0
  96. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  97. package/scripts/escrow-blob-mode-report.ts +465 -0
  98. package/scripts/keycloak-admin.ts +108 -0
  99. package/scripts/keycloak-provider-links.ts +147 -0
  100. package/scripts/migrate-signing-authority-seeds-local.sh +51 -0
  101. package/scripts/migrate-signing-authority-seeds.mjs +105 -0
  102. package/scripts/print-seed-rollout-flags.ts +21 -0
  103. package/scripts/provision-keycloak-users.ts +419 -0
  104. package/seedMigrationApp.test.ts +145 -0
  105. package/seedMigrationApp.ts +50 -0
  106. package/seedMigrationLambda.test.ts +40 -0
  107. package/seedMigrationLambda.ts +19 -0
  108. package/serverless-local.yml +28 -1
  109. package/serverless.function-env.cjs +58 -0
  110. package/serverless.yml +294 -26
  111. package/src/accesslayer/notifications/index.ts +9 -2
  112. package/src/accesslayer/signing-authority/create.ts +11 -7
  113. package/src/accesslayer/signing-authority/index.ts +4 -1
  114. package/src/app.ts +4 -0
  115. package/src/cache/challenges.ts +12 -2
  116. package/src/cache/getDel.test.ts +34 -0
  117. package/src/cache/getDel.ts +32 -0
  118. package/src/cache/in-memory-lru.ts +1 -0
  119. package/src/cache/index.ts +1 -1
  120. package/src/cache/login-tickets.ts +26 -0
  121. package/src/cache/recoverySessions.ts +252 -0
  122. package/src/config/applyDockerStageConfig.ts +6 -0
  123. package/src/config/cacheEnvironment.ts +34 -0
  124. package/src/config/environment.test.ts +159 -0
  125. package/src/config/environment.ts +164 -0
  126. package/src/config/escrowBundleMode.test.ts +52 -0
  127. package/src/config/escrowDeployment.test.ts +114 -0
  128. package/src/config/oidcEnvironment.ts +59 -0
  129. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  130. package/src/config/stageConfig.test.ts +26 -0
  131. package/src/config/stageConfig.ts +24 -0
  132. package/src/docker-entry.ts +13 -0
  133. package/src/helpers/auth.helpers.ts +68 -30
  134. package/src/helpers/escrowCancelLink.test.ts +60 -0
  135. package/src/helpers/escrowCancelLink.ts +38 -0
  136. package/src/helpers/escrowCancelToken.test.ts +105 -0
  137. package/src/helpers/escrowCancelToken.ts +47 -0
  138. package/src/helpers/keycloak.helpers.ts +120 -0
  139. package/src/helpers/learnCard.helpers.test.ts +11 -1
  140. package/src/helpers/learnCard.helpers.ts +57 -45
  141. package/src/helpers/learnCard.signingAuthority.test.ts +194 -0
  142. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  143. package/src/helpers/login-ticket.helpers.ts +91 -0
  144. package/src/helpers/oidc.helpers.ts +354 -0
  145. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  146. package/src/helpers/rate-limit.helpers.ts +73 -0
  147. package/src/helpers/rateLimit.helpers.ts +112 -0
  148. package/src/helpers/seedEncryption.helpers.test.ts +427 -0
  149. package/src/helpers/seedEncryption.helpers.ts +372 -0
  150. package/src/helpers/signingAuthority.helpers.ts +4 -1
  151. package/src/helpers/social-token.helpers.ts +144 -0
  152. package/src/index.ts +1 -0
  153. package/src/jobs/escrowBlobRewrap.ts +121 -0
  154. package/src/jobs/escrowHoldReminders.ts +177 -0
  155. package/src/migrations/signingAuthoritySeeds.ts +430 -0
  156. package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
  157. package/src/models/AuthSubject.ts +147 -0
  158. package/src/models/EscrowHold.ts +541 -0
  159. package/src/models/SigningAuthority.ts +14 -3
  160. package/src/models/UserKey.escrowPin.test.ts +49 -0
  161. package/src/models/UserKey.ts +1109 -40
  162. package/src/models/index.ts +2 -0
  163. package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
  164. package/src/oidc.ts +371 -0
  165. package/src/routes/auth.ts +173 -0
  166. package/src/routes/credentials.ts +8 -0
  167. package/src/routes/escrow.ts +1073 -0
  168. package/src/routes/firebase.ts +90 -48
  169. package/src/routes/index.ts +98 -5
  170. package/src/routes/keys.ts +1022 -174
  171. package/src/routes/qr-login.ts +51 -53
  172. package/src/routes/signing-authority.ts +39 -1
  173. package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
  174. package/src/services/escrow-enclave/README.md +122 -0
  175. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  176. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  177. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  178. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  179. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  180. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  181. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  182. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  183. package/src/services/escrow-enclave/cancellation.ts +59 -0
  184. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  185. package/src/services/escrow-enclave/index.ts +174 -0
  186. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  187. package/src/services/escrow-enclave/notifications.ts +348 -0
  188. package/src/services/escrow-enclave/production.test.ts +34 -0
  189. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  190. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  191. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  192. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  193. package/src/services/escrow-enclave/softwareEnclave.test.ts +450 -0
  194. package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
  195. package/src/services/escrow-enclave/types.ts +122 -0
  196. package/src/types/seed-encryption.ts +51 -0
  197. package/src/types/seed-migration.ts +61 -0
  198. package/test/auth-tickets.spec.ts +480 -0
  199. package/test/credentials.spec.ts +2 -0
  200. package/test/helpers/keycloak-broker.ts +181 -0
  201. package/test/helpers/live-mongo.ts +9 -0
  202. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  203. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  204. package/test/keycloak-admin.spec.ts +58 -0
  205. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  206. package/test/keycloak-migration.integration.spec.ts +266 -0
  207. package/test/keycloak-provider-links.spec.ts +201 -0
  208. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  209. package/test/keycloak-verify.integration.spec.ts +100 -0
  210. package/test/keycloak-verify.spec.ts +307 -0
  211. package/test/keys-escrow.spec.ts +2328 -0
  212. package/test/keys-identity-rebind.spec.ts +456 -0
  213. package/test/keys-lifecycle-invariants.spec.ts +785 -0
  214. package/test/keys-recovery-confirmation.spec.ts +586 -0
  215. package/test/keys-recovery-rate-limit.spec.ts +123 -0
  216. package/test/keys-secret-leakage.spec.ts +265 -0
  217. package/test/models/authSubjectIndexes.spec.ts +132 -0
  218. package/test/models/escrow.spec.ts +572 -0
  219. package/test/notifications.spec.ts +4 -0
  220. package/test/oidc-e2e/broker.spec.ts +116 -0
  221. package/test/oidc-e2e/compose.yaml +33 -0
  222. package/test/oidc-e2e/fixtures.ts +245 -0
  223. package/test/oidc-e2e/lock.test.mjs +73 -0
  224. package/test/oidc-e2e/playwright.config.ts +21 -0
  225. package/test/oidc-e2e/run.mjs +312 -0
  226. package/test/oidc-e2e/tsconfig.json +10 -0
  227. package/test/oidc-e2e/types.ts +21 -0
  228. package/test/oidc.integration.spec.ts +54 -0
  229. package/test/oidc.spec.ts +697 -0
  230. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  231. package/test/provision-keycloak-users.spec.ts +299 -0
  232. package/test/signing-authority-http.spec.ts +161 -0
  233. package/test/signing-authority-seed-migration.spec.ts +414 -0
  234. package/test/signing-authority.spec.ts +90 -0
  235. package/tsconfig.build.json +1 -1
  236. package/tsconfig.json +1 -0
  237. package/vitest.config.ts +24 -1
  238. package/vitest.integration.config.ts +25 -3
  239. package/dist/cache/signing-authority.d.ts +0 -6
  240. package/src/cache/signing-authority.ts +0 -37
@@ -0,0 +1,105 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ /**
4
+ * Boot contract for the dedicated OIDC Lambda. The OIDC function runs under a
5
+ * role that cannot read the runtime-secrets bundle and is deployed without
6
+ * SEED/MONGO/Postmark/Firebase, so its module graph must import and its handler
7
+ * must initialise using ONLY the focused OIDC/cache environments. These tests
8
+ * assert that graph boots with those secrets absent and that the focused schema
9
+ * still enforces the issuer/audience contract.
10
+ */
11
+
12
+ const BOOT_BLOCKERS = [
13
+ 'SEED',
14
+ 'MONGO_URI',
15
+ 'MONGO_DB_NAME',
16
+ 'SA_SEED_KMS_KEY_ARN',
17
+ 'SA_SEED_LOCAL_KEK',
18
+ 'POSTMARK_SERVER_TOKEN',
19
+ 'POSTMARK_FROM_EMAIL',
20
+ 'GOOGLE_APPLICATION_CREDENTIAL',
21
+ 'RUNTIME_SECRETS_ID',
22
+ ] as const;
23
+
24
+ const stubBaseEnv = (): void => {
25
+ vi.stubEnv('NODE_ENV', 'production');
26
+ vi.stubEnv('LAMBDA_STAGE', 'prod');
27
+ vi.stubEnv('IS_OFFLINE', 'false');
28
+ vi.stubEnv('IS_E2E_TEST', 'false');
29
+ vi.stubEnv('OIDC_ISSUER', 'https://issuer.test');
30
+ vi.stubEnv('OIDC_CLIENT_ID', 'keycloak-broker');
31
+ vi.stubEnv('OIDC_CLIENT_SECRET', 'secret');
32
+ vi.stubEnv('OIDC_REDIRECT_URIS', '');
33
+ vi.stubEnv('OIDC_SIGNING_KEY_JWK', '');
34
+ vi.stubEnv('OIDC_SIGNING_KEY_SECRET_ID', '');
35
+ vi.stubEnv('KEYCLOAK_ISSUERS', 'https://kc.test/realms/test');
36
+ vi.stubEnv('KEYCLOAK_AUDIENCES', 'account');
37
+ for (const key of BOOT_BLOCKERS) vi.stubEnv(key, '');
38
+ };
39
+
40
+ beforeEach(() => {
41
+ vi.resetModules();
42
+ stubBaseEnv();
43
+ });
44
+
45
+ afterEach(() => {
46
+ vi.unstubAllEnvs();
47
+ vi.resetModules();
48
+ });
49
+
50
+ describe('OIDC Lambda boot contract', () => {
51
+ it('imports the focused OIDC environment without SEED/MONGO/Postmark present', async () => {
52
+ for (const key of BOOT_BLOCKERS) expect(process.env[key]).toBe('');
53
+
54
+ const module = await import('../src/config/oidcEnvironment');
55
+
56
+ expect(module.environment.OIDC_ISSUER).toBe('https://issuer.test');
57
+ expect(module.environment.OIDC_CLIENT_ID).toBe('keycloak-broker');
58
+ expect(module.environment.KEYCLOAK_AUDIENCES).toBe('account');
59
+ });
60
+
61
+ it('imports the focused cache environment without SEED/MONGO present', async () => {
62
+ const module = await import('../src/config/cacheEnvironment');
63
+
64
+ expect(module.environment.REDIS_HOST).toBeUndefined();
65
+ expect(module.environment.REDIS_PORT).toBeUndefined();
66
+ });
67
+
68
+ it('boots the OIDC handler module graph with core boot secrets absent', async () => {
69
+ const lambdaApp = await import('../oidcLambdaApp');
70
+
71
+ expect(typeof lambdaApp.oidcHandler).toBe('function');
72
+ });
73
+
74
+ it('builds the OIDC Fastify app without touching the full environment schema', async () => {
75
+ const fullSchema = vi.fn();
76
+ vi.doMock('../src/config/environment', () => {
77
+ fullSchema();
78
+ throw new Error('full environment schema must not load on the OIDC boot path');
79
+ });
80
+
81
+ const module = await import('../src/oidc');
82
+ expect(typeof module.app.inject).toBe('function');
83
+ expect(fullSchema).not.toHaveBeenCalled();
84
+
85
+ vi.doUnmock('../src/config/environment');
86
+ });
87
+
88
+ it('enforces the issuer requires audience contract in the focused schema', async () => {
89
+ const { parseOidcEnvironment } = await import('../src/config/oidcEnvironment');
90
+
91
+ expect(() =>
92
+ parseOidcEnvironment({
93
+ KEYCLOAK_ISSUERS: 'https://kc.test/realms/test',
94
+ KEYCLOAK_AUDIENCES: '',
95
+ })
96
+ ).toThrow(/KEYCLOAK_AUDIENCES/);
97
+
98
+ expect(() =>
99
+ parseOidcEnvironment({
100
+ KEYCLOAK_ISSUERS: 'https://kc.test/realms/test',
101
+ KEYCLOAK_AUDIENCES: 'account',
102
+ })
103
+ ).not.toThrow();
104
+ });
105
+ });
@@ -0,0 +1,299 @@
1
+ import { ObjectId } from 'mongodb';
2
+ import { afterEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ find: vi.fn(),
6
+ countDocuments: vi.fn().mockResolvedValue(0),
7
+ findUsers: vi.fn(),
8
+ findSubject: vi.fn(),
9
+ findOne: vi.fn(),
10
+ updateOne: vi.fn(),
11
+ createSubject: vi.fn(),
12
+ closeCursor: vi.fn(),
13
+ }));
14
+ vi.mock('../src/mongo', () => ({ client: { close: vi.fn() } }));
15
+ vi.mock('../src/models/UserKey', () => ({ getUserKeysCollection: () => mocks }));
16
+ vi.mock('../src/models/AuthSubject', () => ({
17
+ getAuthSubjectsCollection: () => ({ findOne: mocks.findSubject }),
18
+ getOrCreateAuthSubject: mocks.createSubject,
19
+ }));
20
+ vi.mock('../scripts/keycloak-admin', async importOriginal => ({
21
+ ...(await importOriginal<typeof import('../scripts/keycloak-admin')>()),
22
+ createKeycloakAdmin: async () => ({ findUsers: mocks.findUsers }),
23
+ }));
24
+ import { provisionKeycloakUsers } from '../scripts/provision-keycloak-users';
25
+ import { KeycloakAdminError, type createKeycloakAdmin } from '../scripts/keycloak-admin';
26
+ import type { FirebaseReader } from '../scripts/keycloak-provider-links';
27
+
28
+ afterEach((): void => {
29
+ vi.restoreAllMocks();
30
+ vi.resetAllMocks();
31
+ mocks.countDocuments.mockResolvedValue(0);
32
+ });
33
+
34
+ const setupUser = (
35
+ mapped: boolean
36
+ ): {
37
+ admin: Awaited<ReturnType<typeof createKeycloakAdmin>>;
38
+ firebase: FirebaseReader;
39
+ } => {
40
+ const row = {
41
+ _id: new ObjectId(),
42
+ contactMethod: { type: 'email', value: 'person@example.com' },
43
+ authProviders: [
44
+ { type: 'firebase', id: 'firebase-uid' },
45
+ ...(mapped ? [{ type: 'keycloak', id: 'keycloak-id' }] : []),
46
+ ],
47
+ };
48
+ mocks.find.mockReturnValue({
49
+ sort: (): AsyncIterable<typeof row> => ({
50
+ [Symbol.asyncIterator]: (): AsyncIterator<typeof row> => {
51
+ let done = false;
52
+ return {
53
+ return: mocks.closeCursor.mockResolvedValue({ done: true, value: undefined }),
54
+ next: async (): Promise<IteratorResult<typeof row>> => {
55
+ if (done) return { done: true, value: undefined };
56
+ done = true;
57
+ return { done: false, value: row };
58
+ },
59
+ };
60
+ },
61
+ }),
62
+ });
63
+ mocks.countDocuments.mockResolvedValueOnce(1).mockResolvedValue(0);
64
+ mocks.findOne.mockResolvedValue(null);
65
+ mocks.findSubject.mockResolvedValue({ subject: 'subject-id' });
66
+ mocks.updateOne.mockResolvedValue({ modifiedCount: 1 });
67
+ vi.spyOn(process.stdout, 'write').mockReturnValue(true);
68
+ return {
69
+ admin: {
70
+ findUsers: vi.fn().mockResolvedValue([
71
+ {
72
+ id: 'keycloak-id',
73
+ email: 'person@example.com',
74
+ emailVerified: true,
75
+ enabled: true,
76
+ },
77
+ ]),
78
+ links: vi
79
+ .fn()
80
+ .mockResolvedValue([
81
+ { identityProvider: 'lca-api', userId: 'subject-id', userName: 'subject-id' },
82
+ ]),
83
+ request: vi.fn().mockResolvedValue(new Response(null, { status: 204 })),
84
+ },
85
+ firebase: {
86
+ getUsers: vi.fn().mockResolvedValue({
87
+ users: [
88
+ {
89
+ uid: 'firebase-uid',
90
+ email: 'person@example.com',
91
+ disabled: false,
92
+ emailVerified: true,
93
+ providerData: [
94
+ { providerId: 'google.com', uid: 'google-sub' },
95
+ { providerId: 'apple.com', uid: 'apple-sub' },
96
+ ],
97
+ },
98
+ ],
99
+ }),
100
+ },
101
+ };
102
+ };
103
+
104
+ describe('provisioning social providers', (): void => {
105
+ it.each(['google', 'apple'])(
106
+ 'refuses a conflicting %s link before any Mongo or Keycloak write',
107
+ async alias => {
108
+ const dependencies = setupUser(false);
109
+ mocks.findSubject.mockResolvedValue(null);
110
+ vi.mocked(dependencies.admin.links).mockResolvedValue([
111
+ { identityProvider: alias, userId: 'other-subject', userName: 'other' },
112
+ ]);
113
+ expect(await provisionKeycloakUsers({ apply: true }, dependencies)).toMatchObject({
114
+ refused: 1,
115
+ conflicts: 1,
116
+ linked: 0,
117
+ mapped: 0,
118
+ });
119
+ expect(dependencies.admin.request).not.toHaveBeenCalled();
120
+ expect(mocks.createSubject).not.toHaveBeenCalled();
121
+ expect(mocks.updateOne).not.toHaveBeenCalled();
122
+ }
123
+ );
124
+ it.each([
125
+ { disabled: true, emailVerified: true, email: 'person@example.com' },
126
+ { disabled: false, emailVerified: false, email: 'person@example.com' },
127
+ { disabled: false, emailVerified: true, email: 'other@example.com' },
128
+ ])('refuses unsafe Firebase ownership: %j', async (attributes): Promise<void> => {
129
+ const dependencies = setupUser(false);
130
+ vi.mocked(dependencies.firebase.getUsers).mockResolvedValueOnce({
131
+ users: [{ uid: 'firebase-uid', providerData: [], ...attributes }],
132
+ });
133
+ expect(await provisionKeycloakUsers({ apply: true }, dependencies)).toMatchObject({
134
+ refused: 1,
135
+ linked: 0,
136
+ });
137
+ expect(dependencies.admin.request).not.toHaveBeenCalled();
138
+ expect(mocks.createSubject).not.toHaveBeenCalled();
139
+ expect(mocks.updateOne).not.toHaveBeenCalled();
140
+ });
141
+
142
+ it('closes the cursor when Firebase discovery fails without advancing the checkpoint', async (): Promise<void> => {
143
+ const dependencies = setupUser(true);
144
+ vi.mocked(dependencies.firebase.getUsers).mockRejectedValueOnce(
145
+ new Error('discovery failed')
146
+ );
147
+ await expect(provisionKeycloakUsers({ apply: true }, dependencies)).rejects.toThrow(
148
+ 'discovery failed'
149
+ );
150
+ expect(mocks.closeCursor).toHaveBeenCalledTimes(1);
151
+ expect(process.stdout.write).toHaveBeenCalledWith(
152
+ expect.stringContaining('Last processed id: (none)')
153
+ );
154
+ });
155
+
156
+ it('reports a successful Google link when Apple fails and closes the cursor', async (): Promise<void> => {
157
+ const dependencies = setupUser(true);
158
+ vi.mocked(dependencies.admin.request)
159
+ .mockResolvedValueOnce(new Response(null, { status: 204 }))
160
+ .mockRejectedValueOnce(new KeycloakAdminError(503));
161
+ await expect(provisionKeycloakUsers({ apply: true }, dependencies)).rejects.toThrow('503');
162
+ expect(mocks.closeCursor).toHaveBeenCalledTimes(1);
163
+ expect(process.stdout.write).toHaveBeenCalledWith(expect.stringContaining('"linked":1'));
164
+ expect(process.stdout.write).toHaveBeenCalledWith(
165
+ expect.stringContaining('Last processed id: (none)')
166
+ );
167
+ });
168
+
169
+ it('reports conflicts in the provisioning summary and continues to the other provider', async (): Promise<void> => {
170
+ const dependencies = setupUser(true);
171
+ vi.mocked(dependencies.admin.request).mockRejectedValueOnce(new KeycloakAdminError(409));
172
+ expect(await provisionKeycloakUsers({ apply: true }, dependencies)).toMatchObject({
173
+ conflicts: 1,
174
+ linked: 1,
175
+ });
176
+ expect(mocks.updateOne).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it('dry-runs mapped users without writes', async (): Promise<void> => {
180
+ const dependencies = setupUser(true);
181
+ expect(await provisionKeycloakUsers({}, dependencies)).toMatchObject({
182
+ wouldLink: 2,
183
+ linked: 0,
184
+ });
185
+ expect(dependencies.admin.request).not.toHaveBeenCalled();
186
+ expect(mocks.updateOne).not.toHaveBeenCalled();
187
+ expect(mocks.createSubject).not.toHaveBeenCalled();
188
+ });
189
+ it('pre-links already mapped users without rewriting mappings or subjects', async (): Promise<void> => {
190
+ const dependencies = setupUser(true);
191
+ const result = await provisionKeycloakUsers({ apply: true }, dependencies);
192
+ expect(result).toMatchObject({ processed: 1, linked: 2, mapped: 0, skipped: 0 });
193
+ expect(dependencies.firebase.getUsers).toHaveBeenCalledExactlyOnceWith([
194
+ { uid: 'firebase-uid' },
195
+ ]);
196
+ expect(mocks.updateOne).not.toHaveBeenCalled();
197
+ expect(mocks.createSubject).not.toHaveBeenCalled();
198
+ });
199
+
200
+ it('creates a new user, subject and mapping then links both providers', async (): Promise<void> => {
201
+ const dependencies = setupUser(false);
202
+ vi.mocked(dependencies.admin.findUsers).mockResolvedValueOnce([]);
203
+ mocks.findSubject.mockResolvedValueOnce(null);
204
+ mocks.createSubject.mockResolvedValueOnce({ subject: 'subject-id' });
205
+ const result = await provisionKeycloakUsers({ apply: true }, dependencies);
206
+ expect(result).toMatchObject({ mapped: 1, linked: 2 });
207
+ expect(dependencies.admin.request).toHaveBeenCalledWith(
208
+ '/users',
209
+ expect.objectContaining({ method: 'POST' })
210
+ );
211
+ expect(dependencies.admin.request).toHaveBeenCalledWith(
212
+ '/users/keycloak-id/federated-identity/lca-api',
213
+ expect.objectContaining({ method: 'POST' })
214
+ );
215
+ expect(mocks.updateOne).toHaveBeenCalledTimes(1);
216
+ });
217
+
218
+ it('dry-run discovers providers but never writes Keycloak or Mongo resources', async (): Promise<void> => {
219
+ const dependencies = setupUser(false);
220
+ vi.mocked(dependencies.admin.findUsers).mockResolvedValueOnce([]);
221
+ mocks.findSubject.mockResolvedValueOnce(null);
222
+ const result = await provisionKeycloakUsers({}, dependencies);
223
+ expect(result).toMatchObject({ linked: 0, wouldLink: 2, mapped: 0 });
224
+ expect(dependencies.admin.request).not.toHaveBeenCalled();
225
+ expect(mocks.createSubject).not.toHaveBeenCalled();
226
+ expect(mocks.updateOne).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it('refuses a missing Firebase UID rather than reporting no social provider', async (): Promise<void> => {
230
+ const dependencies = setupUser(true);
231
+ vi.mocked(dependencies.firebase.getUsers).mockResolvedValueOnce({ users: [] });
232
+ const result = await provisionKeycloakUsers({ apply: true }, dependencies);
233
+ expect(result).toMatchObject({ refused: 1, noSocialProvider: 0, linked: 0 });
234
+ expect(dependencies.admin.request).not.toHaveBeenCalled();
235
+ });
236
+
237
+ it('refuses a mapped Keycloak ID different from the email match', async (): Promise<void> => {
238
+ const dependencies = setupUser(true);
239
+ vi.mocked(dependencies.admin.findUsers).mockResolvedValueOnce([
240
+ { id: 'other-id', email: 'person@example.com', emailVerified: true, enabled: true },
241
+ ]);
242
+ expect(await provisionKeycloakUsers({ apply: true }, dependencies)).toMatchObject({
243
+ refused: 1,
244
+ linked: 0,
245
+ });
246
+ expect(dependencies.admin.request).not.toHaveBeenCalled();
247
+ });
248
+ });
249
+
250
+ describe('provisioning resume', (): void => {
251
+ const skippedRow = {
252
+ contactMethod: { type: 'phone', value: '+15555550100' },
253
+ authProviders: [{ type: 'firebase', id: 'firebase-id' }],
254
+ };
255
+
256
+ it('uses an exclusive ObjectId cursor', async (): Promise<void> => {
257
+ const ids = [1, 2, 3].map(value => new ObjectId(value.toString().padStart(24, '0')));
258
+ const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
259
+ const sort = vi.fn();
260
+ mocks.find.mockImplementation((filter: { _id: { $gt: ObjectId } }) => {
261
+ const rows = ids.filter(id => id.toHexString() > filter._id.$gt.toHexString());
262
+ sort.mockReturnValue({
263
+ async *[Symbol.asyncIterator]() {
264
+ for (const _id of rows) yield { _id, ...skippedRow };
265
+ },
266
+ });
267
+ return { sort };
268
+ });
269
+ const summary = await provisionKeycloakUsers({
270
+ after: ids[1]!.toHexString(),
271
+ linkProviders: false,
272
+ });
273
+ expect(sort).toHaveBeenCalledWith({ _id: 1 });
274
+ expect(summary.processed).toBe(1);
275
+ expect(summary.skipped).toBe(1);
276
+ expect(mocks.findUsers).not.toHaveBeenCalled();
277
+ expect(mocks.findSubject).not.toHaveBeenCalled();
278
+ expect(output).toHaveBeenCalledWith(
279
+ expect.stringContaining(`--after ${ids[2]!.toHexString()}`)
280
+ );
281
+ });
282
+
283
+ it('reports only the completed cursor on failure', async (): Promise<void> => {
284
+ const id = new ObjectId();
285
+ const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
286
+ mocks.find.mockReturnValue({
287
+ sort: () => ({
288
+ async *[Symbol.asyncIterator]() {
289
+ yield { _id: id, ...skippedRow };
290
+ throw new Error('connection lost');
291
+ },
292
+ }),
293
+ });
294
+ await expect(provisionKeycloakUsers({ linkProviders: false })).rejects.toThrow(
295
+ 'connection lost'
296
+ );
297
+ expect(output).toHaveBeenCalledWith(expect.stringContaining(`--after ${id.toHexString()}`));
298
+ });
299
+ });
@@ -0,0 +1,161 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { APIGatewayProxyEventV2, Context as LambdaContext } from 'aws-lambda';
3
+ import { client, mongodb } from '@mongo';
4
+ import { SigningAuthorities } from '@accesslayer/signing-authority';
5
+ import {
6
+ decryptSigningAuthoritySeed,
7
+ seedEncryption,
8
+ SeedEncryptionError,
9
+ } from '@helpers/seedEncryption.helpers';
10
+ import { createOpenApiAwsLambdaHandler } from '@helpers/shim';
11
+ import { appRouter } from '../src/app';
12
+ import { getUser } from './helpers/getClient';
13
+ import {
14
+ runSeedMigrationBatch,
15
+ SEED_MIGRATION_RECEIPTS_COLLECTION,
16
+ SEED_MIGRATION_STATE_COLLECTION,
17
+ } from '../src/migrations/signingAuthoritySeeds';
18
+
19
+ let user: Awaited<ReturnType<typeof getUser>>;
20
+ const handler = createOpenApiAwsLambdaHandler({
21
+ router: appRouter,
22
+ createContext: async () => ({
23
+ domain: 'localhost:3000',
24
+ tenant: { id: 'learncard', emailBranding: {}, resolvedVia: 'default' as const },
25
+ user: { did: user.learnCard.id.did(), isChallengeValid: true, authorizedDid: true },
26
+ }),
27
+ });
28
+ const request = async (path: string, body: unknown) =>
29
+ handler(
30
+ {
31
+ version: '2.0',
32
+ routeKey: 'ANY /api/{trpc+}',
33
+ rawPath: `/api${path}`,
34
+ rawQueryString: '',
35
+ headers: { 'content-type': 'application/json' },
36
+ pathParameters: { trpc: path.slice(1) },
37
+ requestContext: {
38
+ accountId: 'test',
39
+ apiId: 'test',
40
+ domainName: 'localhost',
41
+ domainPrefix: 'localhost',
42
+ http: {
43
+ method: 'POST',
44
+ path,
45
+ protocol: 'HTTP/1.1',
46
+ sourceIp: '127.0.0.1',
47
+ userAgent: 'test',
48
+ },
49
+ requestId: 'test-request',
50
+ routeKey: 'ANY /api/{trpc+}',
51
+ stage: 'test',
52
+ time: '',
53
+ timeEpoch: 0,
54
+ },
55
+ body: JSON.stringify(body),
56
+ isBase64Encoded: false,
57
+ } satisfies APIGatewayProxyEventV2,
58
+ {} as LambdaContext
59
+ );
60
+
61
+ beforeAll(async () => {
62
+ await client.connect();
63
+ user = await getUser();
64
+ });
65
+ afterAll(async () => {
66
+ await client.close();
67
+ });
68
+ beforeEach(async () => {
69
+ await SigningAuthorities.deleteMany({});
70
+ // Spec files share one database; another file may leave a migration lease or receipts behind.
71
+ await mongodb.collection(SEED_MIGRATION_STATE_COLLECTION).deleteMany({});
72
+ await mongodb.collection(SEED_MIGRATION_RECEIPTS_COLLECTION).deleteMany({});
73
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
74
+ });
75
+ afterEach(() => vi.restoreAllMocks());
76
+
77
+ describe('encrypted signing-authority OpenAPI routes', () => {
78
+ it('keeps the same signing identity after migrating and purging a legacy seed', async () => {
79
+ const created = await request('/signing-authority/create', { name: 'migrated-http' });
80
+ const authority = JSON.parse(created.body);
81
+ const stored = await SigningAuthorities.findOne({ _id: authority._id });
82
+ const seed = await decryptSigningAuthoritySeed(stored!);
83
+ await SigningAuthorities.updateOne(
84
+ { _id: authority._id },
85
+ {
86
+ $set: { seed },
87
+ $unset: { encryptedSeed: '', encryptedDek: '', keyVersion: '' },
88
+ }
89
+ );
90
+ for (const phase of ['prepare', 'verify', 'purge'] as const) {
91
+ const result = await runSeedMigrationBatch(
92
+ mongodb,
93
+ { phase },
94
+ { encryptedWritesEnabled: true }
95
+ );
96
+ expect(result.done).toBe(true);
97
+ }
98
+ expect(await SigningAuthorities.findOne({ _id: authority._id })).not.toHaveProperty('seed');
99
+ const response = await request('/credentials/issue', {
100
+ credential: user.learnCard.invoke.newCredential(),
101
+ signingAuthority: authority,
102
+ });
103
+ expect(response.statusCode).toBe(200);
104
+ const signed = JSON.parse(response.body);
105
+ expect(signed.issuer).toBe(authority.did);
106
+ expect((await user.learnCard.invoke.verifyCredential(signed)).errors).toHaveLength(0);
107
+ });
108
+
109
+ it('creates ciphertext-only records and issues a verifiable credential over HTTP', async () => {
110
+ const created = await request('/signing-authority/create', { name: 'http' });
111
+ expect(created.statusCode).toBe(200);
112
+ const authority = JSON.parse(created.body);
113
+ expect(authority).not.toHaveProperty('seed');
114
+ expect(authority).not.toHaveProperty('encryptedSeed');
115
+ expect(await SigningAuthorities.findOne({ name: 'http' })).not.toHaveProperty('seed');
116
+ const response = await request('/credentials/issue', {
117
+ credential: user.learnCard.invoke.newCredential(),
118
+ signingAuthority: authority,
119
+ });
120
+ expect(response.statusCode).toBe(200);
121
+ const verification = await user.learnCard.invoke.verifyCredential(
122
+ JSON.parse(response.body)
123
+ );
124
+ expect(verification.errors).toHaveLength(0);
125
+ });
126
+
127
+ it.each(['kms_access_denied', 'kms_unavailable', 'authentication_failed'] as const)(
128
+ 'returns a generic 500 and structured logs on %s',
129
+ async category => {
130
+ const created = await request('/signing-authority/create', { name: category });
131
+ const authority = JSON.parse(created.body);
132
+ vi.spyOn(seedEncryption, 'decrypt').mockRejectedValueOnce(
133
+ new SeedEncryptionError(category, 'request-1')
134
+ );
135
+ const response = await request('/credentials/issue', {
136
+ credential: user.learnCard.invoke.newCredential(),
137
+ signingAuthority: authority,
138
+ });
139
+ expect(response.statusCode).toBe(500);
140
+ expect(response.body).toContain('Signing authority key is unavailable');
141
+ expect(response.body).not.toContain(category);
142
+ expect(response.body).not.toContain('request-1');
143
+ expect(console.error).toHaveBeenCalledWith(
144
+ expect.objectContaining({
145
+ event: 'signing_authority_seed_failure',
146
+ operation: 'decrypt',
147
+ category,
148
+ })
149
+ );
150
+ }
151
+ );
152
+
153
+ it('does not insert a record when encryption fails during creation', async () => {
154
+ vi.spyOn(seedEncryption, 'encrypt').mockRejectedValueOnce(
155
+ new SeedEncryptionError('kms_unavailable')
156
+ );
157
+ const response = await request('/signing-authority/create', { name: 'failed-create' });
158
+ expect(response.statusCode).toBe(500);
159
+ expect(await SigningAuthorities.countDocuments()).toBe(0);
160
+ });
161
+ });