@learncard/lca-api-service 1.4.0 → 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 (188) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +43 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/config/config.dev.json +23 -0
  6. package/config/config.example.json +22 -0
  7. package/config/config.json +1 -0
  8. package/config/config.production.json +12 -0
  9. package/config/config.scouts.dev.json +11 -0
  10. package/config/config.scouts.production.json +11 -0
  11. package/dist/app.d.ts +67 -3
  12. package/dist/cache/getDel.d.ts +2 -0
  13. package/dist/cache/getDel.test.d.ts +1 -0
  14. package/dist/cache/login-tickets.d.ts +12 -0
  15. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  16. package/dist/config/cacheEnvironment.d.ts +16 -0
  17. package/dist/config/environment.d.ts +101 -0
  18. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  19. package/dist/config/escrowDeployment.test.d.ts +1 -0
  20. package/dist/config/oidcEnvironment.d.ts +74 -0
  21. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  22. package/dist/config/stageConfig.d.ts +62 -0
  23. package/dist/config/stageConfig.test.d.ts +1 -0
  24. package/dist/didkit_wasm_bg.wasm +0 -0
  25. package/dist/docker-entry.d.ts +1 -1
  26. package/dist/helpers/auth.helpers.d.ts +1 -1
  27. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  28. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  29. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  30. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  31. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  32. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  33. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  34. package/dist/helpers/oidc.helpers.d.ts +47 -0
  35. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  36. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  37. package/dist/helpers/social-token.helpers.d.ts +19 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +2 -2
  40. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  41. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  42. package/dist/models/AuthSubject.d.ts +20 -0
  43. package/dist/models/EscrowHold.d.ts +79 -1
  44. package/dist/models/Notification.d.ts +5 -0
  45. package/dist/models/UserKey.d.ts +11 -1
  46. package/dist/models/index.d.ts +1 -0
  47. package/dist/oidc.d.ts +5 -0
  48. package/dist/routes/auth.d.ts +31 -0
  49. package/dist/routes/escrow.d.ts +18 -4
  50. package/dist/routes/keys.d.ts +1 -0
  51. package/dist/routes/notifications.d.ts +5 -0
  52. package/dist/routes/signing-authority.d.ts +14 -0
  53. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  54. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  55. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  56. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  57. package/dist/services/escrow-enclave/index.d.ts +16 -0
  58. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  59. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  60. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  61. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  65. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  66. package/dist/services/escrow-enclave/types.d.ts +52 -8
  67. package/dist/types/notifications.d.ts +5 -0
  68. package/esbuildPlugins.cjs +1 -0
  69. package/lambda.ts +27 -117
  70. package/lambdaApp.ts +146 -0
  71. package/oidcLambda.test.ts +38 -0
  72. package/oidcLambda.ts +18 -0
  73. package/oidcLambdaApp.ts +11 -0
  74. package/package.json +23 -13
  75. package/scripts/README.md +88 -0
  76. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  77. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  78. package/scripts/escrow-blob-mode-report.ts +465 -0
  79. package/scripts/keycloak-admin.ts +108 -0
  80. package/scripts/keycloak-provider-links.ts +147 -0
  81. package/scripts/print-seed-rollout-flags.ts +21 -0
  82. package/scripts/provision-keycloak-users.ts +419 -0
  83. package/seedMigrationApp.test.ts +145 -0
  84. package/seedMigrationApp.ts +50 -0
  85. package/seedMigrationLambda.test.ts +30 -135
  86. package/seedMigrationLambda.ts +15 -46
  87. package/serverless-local.yml +25 -1
  88. package/serverless.function-env.cjs +58 -0
  89. package/serverless.yml +172 -37
  90. package/src/accesslayer/notifications/index.ts +9 -2
  91. package/src/accesslayer/signing-authority/index.ts +4 -1
  92. package/src/app.ts +2 -0
  93. package/src/cache/getDel.test.ts +34 -0
  94. package/src/cache/getDel.ts +32 -0
  95. package/src/cache/index.ts +1 -1
  96. package/src/cache/login-tickets.ts +26 -0
  97. package/src/config/applyDockerStageConfig.ts +6 -0
  98. package/src/config/cacheEnvironment.ts +34 -0
  99. package/src/config/environment.test.ts +45 -2
  100. package/src/config/environment.ts +90 -23
  101. package/src/config/escrowBundleMode.test.ts +52 -0
  102. package/src/config/escrowDeployment.test.ts +114 -0
  103. package/src/config/oidcEnvironment.ts +59 -0
  104. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  105. package/src/config/stageConfig.test.ts +26 -0
  106. package/src/config/stageConfig.ts +24 -0
  107. package/src/docker-entry.ts +9 -0
  108. package/src/helpers/auth.helpers.ts +68 -30
  109. package/src/helpers/escrowCancelLink.test.ts +60 -0
  110. package/src/helpers/escrowCancelLink.ts +38 -0
  111. package/src/helpers/escrowCancelToken.test.ts +105 -0
  112. package/src/helpers/escrowCancelToken.ts +47 -0
  113. package/src/helpers/keycloak.helpers.ts +120 -0
  114. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  115. package/src/helpers/login-ticket.helpers.ts +91 -0
  116. package/src/helpers/oidc.helpers.ts +354 -0
  117. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  118. package/src/helpers/rate-limit.helpers.ts +73 -0
  119. package/src/helpers/social-token.helpers.ts +144 -0
  120. package/src/index.ts +1 -0
  121. package/src/jobs/escrowBlobRewrap.ts +121 -0
  122. package/src/jobs/escrowHoldReminders.ts +177 -0
  123. package/src/models/AuthSubject.ts +147 -0
  124. package/src/models/EscrowHold.ts +321 -5
  125. package/src/models/UserKey.ts +91 -6
  126. package/src/models/index.ts +1 -0
  127. package/src/oidc.ts +371 -0
  128. package/src/routes/auth.ts +173 -0
  129. package/src/routes/escrow.ts +281 -34
  130. package/src/routes/keys.ts +5 -0
  131. package/src/routes/signing-authority.ts +39 -1
  132. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  133. package/src/services/escrow-enclave/README.md +81 -10
  134. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  135. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  136. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  137. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  141. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  142. package/src/services/escrow-enclave/cancellation.ts +59 -0
  143. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  144. package/src/services/escrow-enclave/index.ts +145 -7
  145. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  146. package/src/services/escrow-enclave/notifications.ts +340 -6
  147. package/src/services/escrow-enclave/production.test.ts +34 -0
  148. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  149. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  150. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  151. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  152. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  153. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  154. package/src/services/escrow-enclave/types.ts +63 -17
  155. package/test/auth-tickets.spec.ts +480 -0
  156. package/test/helpers/keycloak-broker.ts +181 -0
  157. package/test/helpers/live-mongo.ts +9 -0
  158. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  159. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  160. package/test/keycloak-admin.spec.ts +58 -0
  161. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  162. package/test/keycloak-migration.integration.spec.ts +266 -0
  163. package/test/keycloak-provider-links.spec.ts +201 -0
  164. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  165. package/test/keycloak-verify.integration.spec.ts +100 -0
  166. package/test/keycloak-verify.spec.ts +307 -0
  167. package/test/keys-escrow.spec.ts +963 -24
  168. package/test/models/authSubjectIndexes.spec.ts +132 -0
  169. package/test/models/escrow.spec.ts +375 -13
  170. package/test/notifications.spec.ts +4 -0
  171. package/test/oidc-e2e/broker.spec.ts +116 -0
  172. package/test/oidc-e2e/compose.yaml +33 -0
  173. package/test/oidc-e2e/fixtures.ts +245 -0
  174. package/test/oidc-e2e/lock.test.mjs +73 -0
  175. package/test/oidc-e2e/playwright.config.ts +21 -0
  176. package/test/oidc-e2e/run.mjs +312 -0
  177. package/test/oidc-e2e/tsconfig.json +10 -0
  178. package/test/oidc-e2e/types.ts +21 -0
  179. package/test/oidc.integration.spec.ts +54 -0
  180. package/test/oidc.spec.ts +697 -0
  181. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  182. package/test/provision-keycloak-users.spec.ts +299 -0
  183. package/test/signing-authority.spec.ts +50 -0
  184. package/tsconfig.build.json +1 -1
  185. package/tsconfig.json +1 -0
  186. package/vitest.config.ts +24 -1
  187. package/vitest.integration.config.ts +20 -3
  188. package/serverless.escrow-env.cjs +0 -20
@@ -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
+ });
@@ -151,6 +151,56 @@ describe('Signing Authority', () => {
151
151
  }
152
152
  });
153
153
 
154
+ describe('ensureManagedSigningAuthority', () => {
155
+ const ownerDid = 'did:web:localhost%3A4000:app:demo-app';
156
+
157
+ it('should only be callable by authorized service DIDs', async () => {
158
+ await expect(
159
+ noAuthClient.signingAuthority.ensureManagedSigningAuthority({
160
+ ownerDid,
161
+ name: 'app-demo',
162
+ })
163
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
164
+ await expect(
165
+ userA.clients.fullAuth.signingAuthority.ensureManagedSigningAuthority({
166
+ ownerDid,
167
+ name: 'app-demo',
168
+ })
169
+ ).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
170
+ });
171
+
172
+ it('should create an encrypted signing authority for the given owner', async () => {
173
+ const signingAuthority =
174
+ await userA.clients.authorizedDidAuth.signingAuthority.ensureManagedSigningAuthority(
175
+ { ownerDid, name: 'app-demo' }
176
+ );
177
+
178
+ expect(signingAuthority).toMatchObject({
179
+ name: 'app-demo',
180
+ ownerDid,
181
+ did: expect.stringMatching(/^did:/),
182
+ endpoint: expect.stringContaining('/api'),
183
+ });
184
+ const stored = await SigningAuthorities.findOne({ _id: signingAuthority._id });
185
+ expect(stored).not.toHaveProperty('seed');
186
+ expect(stored).toMatchObject({ encryptedSeed: expect.any(String) });
187
+ });
188
+
189
+ it('should return the existing signing authority instead of creating another', async () => {
190
+ const first =
191
+ await userA.clients.authorizedDidAuth.signingAuthority.ensureManagedSigningAuthority(
192
+ { ownerDid, name: 'app-demo' }
193
+ );
194
+ const second =
195
+ await userA.clients.authorizedDidAuth.signingAuthority.ensureManagedSigningAuthority(
196
+ { ownerDid, name: 'app-demo' }
197
+ );
198
+
199
+ expect(second).toEqual(first);
200
+ await expect(SigningAuthorities.countDocuments({ ownerDid })).resolves.toBe(1);
201
+ });
202
+ });
203
+
154
204
  it('should allow you to authorize your signing authority to issue a boost', async () => {
155
205
  await userA.clients.fullAuth.signingAuthority.createSigningAuthority({ name: 'mysa' });
156
206
  await expect(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "extends": "./tsconfig.json",
3
3
  "compilerOptions": {
4
- "resolveJsonModule": false,
4
+ "resolveJsonModule": true,
5
5
  "rootDir": "src"
6
6
  },
7
7
  "exclude": ["./test/**/*"]
package/tsconfig.json CHANGED
@@ -22,6 +22,7 @@
22
22
  "@accesslayer/*": ["src/accesslayer/*"],
23
23
  "@cache": ["src/cache/index.ts"],
24
24
  "@cache/*": ["src/cache/*"],
25
+ "@config/*": ["src/config/*"],
25
26
  "@environment": ["src/config/environment.ts"],
26
27
  "@helpers/*": ["src/helpers/*"],
27
28
  "@instance": ["src/instance.ts"],
package/vitest.config.ts CHANGED
@@ -1,7 +1,30 @@
1
+ import { createRequire } from 'node:module';
1
2
  import { createVitestConfig, nodePreset } from '../../../vitest.shared';
2
3
 
4
+ const require = createRequire(import.meta.url);
5
+ const liveBroker =
6
+ process.env.KEYCLOAK_INTEGRATION === 'true' && process.env.KEYCLOAK_ROUNDTRIP === 'true';
7
+
3
8
  export default createVitestConfig(nodePreset, {
4
9
  test: {
5
- include: ['src/**/*.test.ts', '*Lambda.test.ts'],
10
+ alias: liveBroker ? { '@mongo': require.resolve('./test/helpers/live-mongo.ts') } : {},
11
+ include: [
12
+ 'test/keycloak-admin.spec.ts',
13
+ 'test/keycloak-provider-links.spec.ts',
14
+ 'test/provision-keycloak-users.spec.ts',
15
+ 'src/**/*.test.ts',
16
+ 'scripts/**/*.test.ts',
17
+ '*Lambda.test.ts',
18
+ 'seedMigrationApp.test.ts',
19
+ 'test/keycloak-verify.spec.ts',
20
+ 'test/keycloak-verify.integration.spec.ts',
21
+ 'test/oidc.spec.ts',
22
+ 'test/oidcLambdaApp.boot.spec.ts',
23
+ 'test/auth-tickets.spec.ts',
24
+ 'test/models/authSubjectIndexes.spec.ts',
25
+ 'test/oidc.integration.spec.ts',
26
+ 'test/keycloak-broker-roundtrip.integration.spec.ts',
27
+ 'test/keycloak-migration.integration.spec.ts',
28
+ ],
6
29
  },
7
30
  });
@@ -3,16 +3,33 @@ import { createRequire } from 'node:module';
3
3
  import { createVitestConfig, serviceIntegrationPreset } from '../../../vitest.shared';
4
4
 
5
5
  const require = createRequire(import.meta.url);
6
+ const liveBroker =
7
+ process.env.KEYCLOAK_INTEGRATION === 'true' && process.env.KEYCLOAK_ROUNDTRIP === 'true';
6
8
 
7
9
  export default createVitestConfig(serviceIntegrationPreset, {
8
10
  test: {
9
- globalSetup: './vitest-setup.ts',
10
- include: ['test/**/*.spec.ts'],
11
+ globalSetup: liveBroker ? [] : './vitest-setup.ts',
12
+ include: liveBroker
13
+ ? [
14
+ 'test/keycloak-broker-roundtrip.integration.spec.ts',
15
+ 'test/keycloak-social-broker.integration.spec.ts',
16
+ 'test/keycloak-migration.integration.spec.ts',
17
+ 'test/keycloak-verify.integration.spec.ts',
18
+ 'test/oidc.integration.spec.ts',
19
+ ]
20
+ : ['test/**/*.spec.ts'],
11
21
  env: {
12
22
  IS_E2E_TEST: 'true',
13
23
  ESCROW_RELAY_URL: 'https://escrow-relay.example',
14
24
  ESCROW_RELAY_AUTH_TOKEN: 'relay-auth-token',
15
25
  },
16
- alias: { '@mongo': require.resolve('./test/helpers/mock-mongo.ts') },
26
+ // Fully-mocked unit specs (mock @cache/@models/@environment) run under
27
+ // vitest.config.ts; they must not load the live-Mongo integration setup.
28
+ exclude: ['test/auth-tickets.spec.ts', 'test/oidc.spec.ts', 'test/oidc-e2e/**'],
29
+ alias: {
30
+ '@mongo': require.resolve(
31
+ liveBroker ? './test/helpers/live-mongo.ts' : './test/helpers/mock-mongo.ts'
32
+ ),
33
+ },
17
34
  },
18
35
  });
@@ -1,20 +0,0 @@
1
- // Empty-string keys still count toward Lambda's 4KB env limit, so emit only set values.
2
- // Enclave keys are gated on ESCROW_ENCLAVE_MODE: unsetting that one variable disables
3
- // escrow without deleting the stored key secret.
4
- const RELAY_KEYS = ['ESCROW_RELAY_URL', 'ESCROW_RELAY_AUTH_TOKEN'];
5
-
6
- const ENCLAVE_KEYS = [
7
- 'ESCROW_ENCLAVE_MODE',
8
- 'ESCROW_ENCLAVE_SOFTWARE_PRIVATE_KEYS_JSON',
9
- 'ESCROW_ENCLAVE_ACTIVE_KEY_ID',
10
- 'ESCROW_HOLD_DURATION_MS',
11
- 'ESCROW_HOLD_RESTART_MIN_AGE_MS',
12
- ];
13
-
14
- module.exports = () => {
15
- const keys = process.env.ESCROW_ENCLAVE_MODE ? [...RELAY_KEYS, ...ENCLAVE_KEYS] : RELAY_KEYS;
16
-
17
- return Object.fromEntries(
18
- keys.filter(key => process.env[key]).map(key => [key, process.env[key]])
19
- );
20
- };