@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,312 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import { once } from 'node:events';
3
+ import { createServer } from 'node:http';
4
+ import { createServer as createSocket } from 'node:net';
5
+ import { mkdir, readFile, writeFile, rm, open } from 'node:fs/promises';
6
+ import { tmpdir } from 'node:os';
7
+ import { dirname, resolve, join } from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { promisify } from 'node:util';
10
+ import { randomBytes } from 'node:crypto';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const exec = promisify(execFile);
14
+ const require = createRequire(import.meta.url);
15
+ // Keep the single-worker cleanup contract; arbitrary Playwright config/worker
16
+ // overrides could otherwise make one test clear another test's Redis state.
17
+ const testArgs = process.argv.slice(2);
18
+ for (let index = 0; index < testArgs.length; index++) {
19
+ const option = testArgs[index];
20
+ if (option === '--headed') continue;
21
+ if ((option === '--grep' || option === '--grep-invert') && testArgs[++index]) continue;
22
+ throw new Error('Supported options: --headed, --grep <pattern>, --grep-invert <pattern>');
23
+ }
24
+ const here = dirname(fileURLToPath(import.meta.url));
25
+ const service = resolve(here, '../..');
26
+ const repo = resolve(service, '../../..');
27
+ // Fixed project + exclusive lock bounds this suite's resources.
28
+ // After SIGKILL, stop all suite processes and remove a stale lock manually.
29
+ // Once locked, the next run reaps the stale stack. No shared dev volumes.
30
+ const project = 'learncard-oidc-e2e';
31
+ const lockPath = join(tmpdir(), `${project}.lock`);
32
+ const composeArgs = ['compose', '-p', project, '-f', join(here, 'compose.yaml')];
33
+ let temp;
34
+ let api;
35
+ let playwright;
36
+ let callback;
37
+ let composeEnv;
38
+ let cleaned;
39
+ let ownsLock = false;
40
+ let interrupted = false;
41
+ let apiStartupError;
42
+
43
+ const assertRunning = () => {
44
+ if (interrupted) throw new Error('OIDC E2E interrupted');
45
+ };
46
+
47
+ const compose = (...args) =>
48
+ exec('docker', [...composeArgs, ...args], {
49
+ env: composeEnv ?? process.env,
50
+ timeout: 180_000,
51
+ maxBuffer: 4 * 1024 * 1024,
52
+ });
53
+
54
+ const acquireLock = async () => {
55
+ try {
56
+ const file = await open(lockPath, 'wx', 0o600);
57
+ await file.writeFile(String(process.pid));
58
+ await file.close();
59
+ ownsLock = true;
60
+ } catch (error) {
61
+ if (error.code !== 'EEXIST') throw error;
62
+ const pid = Number(await readFile(lockPath, 'utf8'));
63
+ if (!Number.isInteger(pid) || pid <= 0)
64
+ throw new Error(`Inspect stale lock: ${lockPath}`, { cause: error });
65
+ try {
66
+ process.kill(pid, 0);
67
+ } catch (probe) {
68
+ if (probe.code !== 'ESRCH') throw probe;
69
+ throw new Error(
70
+ `Stale OIDC E2E lock at ${lockPath} (PID ${pid}). Stop all OIDC E2E invocations and their child processes, remove this lock manually, then rerun.`,
71
+ { cause: probe }
72
+ );
73
+ }
74
+ throw new Error(`OIDC E2E is already running (PID ${pid}); refusing to share its data.`, {
75
+ cause: error,
76
+ });
77
+ }
78
+ };
79
+
80
+ const freePort = async () => {
81
+ const socket = createSocket();
82
+ socket.listen(0, '0.0.0.0');
83
+ await once(socket, 'listening');
84
+ const { port } = socket.address();
85
+ await new Promise((resolve, reject) =>
86
+ socket.close(error => (error ? reject(error) : resolve()))
87
+ );
88
+ return port;
89
+ };
90
+
91
+ const stop = async child => {
92
+ if (!child?.pid || child.exitCode !== null || child.signalCode !== null) return;
93
+ const exited = once(child, 'exit');
94
+ child.kill('SIGTERM');
95
+ const timer = setTimeout(() => child.kill('SIGKILL'), 5_000);
96
+ try {
97
+ await exited;
98
+ } finally {
99
+ clearTimeout(timer);
100
+ }
101
+ };
102
+
103
+ const cleanup = () =>
104
+ (cleaned ??= (async () => {
105
+ const errors = [];
106
+ for (const action of [
107
+ () => stop(playwright),
108
+ () => stop(api),
109
+ async () => {
110
+ if (callback) {
111
+ callback.closeAllConnections();
112
+ await new Promise(resolve => callback.close(resolve));
113
+ }
114
+ },
115
+ async () => {
116
+ if (ownsLock) await compose('down', '--volumes', '--remove-orphans');
117
+ },
118
+ async () => {
119
+ if (temp) await rm(temp, { recursive: true, force: true });
120
+ },
121
+ async () => {
122
+ if (ownsLock) await rm(lockPath, { force: true });
123
+ },
124
+ ]) {
125
+ try {
126
+ await action();
127
+ } catch (error) {
128
+ errors.push(error);
129
+ }
130
+ }
131
+ if (errors.length) throw new AggregateError(errors, 'OIDC E2E cleanup failed');
132
+ if (ownsLock)
133
+ console.log(
134
+ 'OIDC E2E cleanup complete: containers, data, callback, and temp files removed.'
135
+ );
136
+ })());
137
+
138
+ // Let the main lifecycle reach finally after any in-flight Compose operation.
139
+ // Running teardown concurrently with startup can otherwise leave late containers.
140
+ for (const signal of ['SIGINT', 'SIGTERM']) {
141
+ process.on(signal, () => {
142
+ interrupted = true;
143
+ playwright?.kill('SIGTERM');
144
+ api?.kill('SIGTERM');
145
+ });
146
+ }
147
+
148
+ const waitFor = async (url, child) => {
149
+ for (let attempt = 0; attempt < 120; attempt++) {
150
+ assertRunning();
151
+ if (child === api && apiStartupError) throw apiStartupError;
152
+ if (child && (child.exitCode !== null || child.signalCode !== null)) {
153
+ throw new Error('lca-api exited before becoming ready');
154
+ }
155
+ try {
156
+ if ((await fetch(url, { signal: AbortSignal.timeout(1_000) })).ok) return;
157
+ } catch {
158
+ /* The service is still starting. */
159
+ }
160
+ await new Promise(resolve => setTimeout(resolve, 500));
161
+ }
162
+ throw new Error(`Service did not become ready: ${url}`);
163
+ };
164
+
165
+ try {
166
+ await acquireLock();
167
+ assertRunning();
168
+ await compose('down', '--volumes', '--remove-orphans');
169
+ // A fixed, lock-protected directory also bounds leftovers after a hard crash.
170
+ temp = join(tmpdir(), `${project}-runtime`);
171
+ await rm(temp, { recursive: true, force: true });
172
+ await mkdir(temp, { mode: 0o700 });
173
+ const apiPort = await freePort();
174
+ const keycloakPort = await freePort();
175
+ const apiUrl = `http://localhost:${apiPort}`;
176
+ const keycloakUrl = `http://localhost:${keycloakPort}`;
177
+ const issuer = `${keycloakUrl}/realms/learncard-oidc-e2e`;
178
+ const adminPassword = randomBytes(24).toString('hex');
179
+ callback = createServer((request, response) => {
180
+ response.writeHead(request.url?.startsWith('/callback?') ? 200 : 404, {
181
+ 'Content-Type': 'text/html',
182
+ 'Cache-Control': 'no-store',
183
+ });
184
+ response.end('<!doctype html><title>OIDC test callback</title>Callback received');
185
+ });
186
+ callback.listen(0, '127.0.0.1');
187
+ await once(callback, 'listening');
188
+ const callbackUrl = `http://127.0.0.1:${callback.address().port}/callback`;
189
+
190
+ const realm = JSON.parse(
191
+ await readFile(join(repo, 'infra/keycloak/realms/learncard-dev-realm.json'), 'utf8')
192
+ );
193
+ realm.realm = 'learncard-oidc-e2e';
194
+ realm.users = [];
195
+ const appClient = realm.clients.find(client => client.clientId === 'learncard-app');
196
+ appClient.redirectUris = [callbackUrl];
197
+ const provider = realm.identityProviders.find(provider => provider.alias === 'lca-api');
198
+ Object.assign(provider.config, {
199
+ issuer: apiUrl,
200
+ authorizationUrl: `${apiUrl}/oidc/authorize`,
201
+ tokenUrl: `http://host.docker.internal:${apiPort}/oidc/token`,
202
+ jwksUrl: `http://host.docker.internal:${apiPort}/oidc/jwks`,
203
+ userInfoUrl: `http://host.docker.internal:${apiPort}/oidc/userinfo`,
204
+ });
205
+ const realmFile = join(temp, 'learncard-oidc-e2e-realm.json');
206
+ await writeFile(realmFile, JSON.stringify(realm));
207
+ composeEnv = {
208
+ ...process.env,
209
+ OIDC_E2E_REALM_FILE: realmFile,
210
+ OIDC_E2E_KEYCLOAK_PORT: String(keycloakPort),
211
+ OIDC_E2E_ADMIN_PASSWORD: adminPassword,
212
+ };
213
+ console.log('Starting disposable Keycloak, Redis, and MongoDB...');
214
+ assertRunning();
215
+ await compose('up', '-d');
216
+ assertRunning();
217
+ const port = async (name, internal) =>
218
+ Number((await compose('port', name, String(internal))).stdout.trim().split(':').at(-1));
219
+ const mongoPort = await port('mongo', 27017);
220
+ const redisPort = await port('redis', 6379);
221
+ const runtime = {
222
+ apiUrl,
223
+ keycloakUrl,
224
+ issuer,
225
+ callbackUrl,
226
+ adminPassword,
227
+ mongoUri: `mongodb://127.0.0.1:${mongoPort}`,
228
+ mongoDbName: 'oidc-e2e',
229
+ redisPort,
230
+ };
231
+ const runtimePath = join(temp, 'runtime.json');
232
+ await writeFile(runtimePath, JSON.stringify(runtime), { mode: 0o600 });
233
+ // Start the actual production HTTP entrypoint with local-only configuration.
234
+ // Empty cwd + explicit dotenv path prevents loading developers' .env secrets.
235
+ const emptyEnv = join(temp, 'empty.env');
236
+ await writeFile(emptyEnv, '');
237
+ assertRunning();
238
+ api = spawn(
239
+ 'bun',
240
+ [
241
+ '--conditions=development',
242
+ '--tsconfig-override',
243
+ join(service, 'tsconfig.json'),
244
+ join(service, 'src/docker-entry.ts'),
245
+ ],
246
+ {
247
+ cwd: temp,
248
+ env: {
249
+ PATH: process.env.PATH,
250
+ HOME: process.env.HOME,
251
+ TMPDIR: process.env.TMPDIR,
252
+ DOTENV_CONFIG_PATH: emptyEnv,
253
+ NODE_ENV: 'development',
254
+ CI: 'true',
255
+ IS_OFFLINE: 'true',
256
+ IS_E2E_TEST: 'true',
257
+ SEED: 'a'.repeat(64),
258
+ PORT: String(apiPort),
259
+ MONGO_URI: runtime.mongoUri,
260
+ MONGO_DB_NAME: runtime.mongoDbName,
261
+ REDIS_HOST: '127.0.0.1',
262
+ REDIS_PORT: String(redisPort),
263
+ OIDC_ISSUER: apiUrl,
264
+ OIDC_CLIENT_ID: 'keycloak-broker',
265
+ OIDC_CLIENT_SECRET: provider.config.clientSecret,
266
+ KEYCLOAK_ISSUERS: issuer,
267
+ KEYCLOAK_AUDIENCES: 'learncard-app',
268
+ },
269
+ stdio: ['ignore', 'inherit', 'inherit'],
270
+ }
271
+ );
272
+ api.once('error', error => {
273
+ apiStartupError = error;
274
+ });
275
+ await waitFor(`${apiUrl}/.well-known/openid-configuration`, api);
276
+ await waitFor(`${issuer}/.well-known/openid-configuration`);
277
+ console.log('Running browser sign-in against the isolated stack...');
278
+ assertRunning();
279
+ playwright = spawn(
280
+ process.execPath,
281
+ [
282
+ require.resolve('@playwright/test/cli'),
283
+ 'test',
284
+ '--config',
285
+ join(here, 'playwright.config.ts'),
286
+ ...testArgs,
287
+ ],
288
+ {
289
+ cwd: service,
290
+ env: {
291
+ ...process.env,
292
+ OIDC_E2E_RUNTIME: runtimePath,
293
+ OIDC_E2E_OUTPUT: join(temp, 'results'),
294
+ },
295
+ stdio: 'inherit',
296
+ }
297
+ );
298
+ const [code] = await once(playwright, 'exit');
299
+ process.exitCode = interrupted ? 130 : (code ?? 1);
300
+ if (process.exitCode && !interrupted)
301
+ console.error((await compose('logs', '--no-color', '--tail', '40', 'keycloak')).stdout);
302
+ } catch (error) {
303
+ if (!interrupted) console.error(error);
304
+ process.exitCode = interrupted ? 130 : 1;
305
+ } finally {
306
+ try {
307
+ await cleanup();
308
+ } catch (error) {
309
+ console.error(error);
310
+ process.exitCode = 1;
311
+ }
312
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "rootDir": ".",
6
+ "moduleResolution": "Bundler"
7
+ },
8
+ "include": ["*.ts"],
9
+ "exclude": []
10
+ }
@@ -0,0 +1,21 @@
1
+ export interface OidcE2eRuntime {
2
+ apiUrl: string;
3
+ keycloakUrl: string;
4
+ issuer: string;
5
+ callbackUrl: string;
6
+ mongoUri: string;
7
+ mongoDbName: string;
8
+ redisPort: number;
9
+ adminPassword: string;
10
+ }
11
+
12
+ export interface LoginTicketResult {
13
+ success: boolean;
14
+ ticket?: string;
15
+ error?: string;
16
+ }
17
+
18
+ export interface TestAccount {
19
+ email: string;
20
+ identityKey: string;
21
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ // Lightweight import contract; the Redis/Mongo-backed full chain is covered by
4
+ // keycloak-broker-roundtrip.integration.spec.ts with KEYCLOAK_ROUNDTRIP=true.
5
+ describe.runIf(process.env.KEYCLOAK_INTEGRATION === 'true')(
6
+ 'lca-api Keycloak broker import',
7
+ () => {
8
+ const issuer =
9
+ process.env.KEYCLOAK_ISSUERS?.split(',')[0] || 'http://localhost:8081/realms/learncard';
10
+
11
+ it('serves discovery from the imported realm', async () => {
12
+ const response = await fetch(`${issuer}/.well-known/openid-configuration`);
13
+ expect(response.status).toBe(200);
14
+ expect(await response.json()).toMatchObject({ issuer });
15
+ });
16
+
17
+ it('redirects to the registered lca-api provider and forwards the ticket hint', async () => {
18
+ const params = new URLSearchParams({
19
+ client_id: 'learncard-app',
20
+ redirect_uri: 'http://localhost:3000/callback',
21
+ response_type: 'code',
22
+ scope: 'openid',
23
+ state: 'broker-test',
24
+ code_challenge: 'x'.repeat(43),
25
+ code_challenge_method: 'S256',
26
+ kc_idp_hint: 'lca-api',
27
+ login_hint: 'synthetic-ticket-not-redeemed',
28
+ });
29
+ const response = await fetch(`${issuer}/protocol/openid-connect/auth?${params}`, {
30
+ redirect: 'manual',
31
+ });
32
+ expect([302, 303]).toContain(response.status);
33
+ const brokerLogin = new URL(response.headers.get('location')!);
34
+ expect(`${brokerLogin.origin}${brokerLogin.pathname}`).toBe(
35
+ `${issuer}/broker/lca-api/login`
36
+ );
37
+ const brokerResponse = await fetch(brokerLogin, {
38
+ redirect: 'manual',
39
+ headers: {
40
+ cookie: response.headers
41
+ .getSetCookie()
42
+ .map(cookie => cookie.split(';')[0])
43
+ .join('; '),
44
+ },
45
+ });
46
+ expect([302, 303]).toContain(brokerResponse.status);
47
+ const url = new URL(brokerResponse.headers.get('location')!);
48
+ expect(`${url.origin}${url.pathname}`).toBe('http://localhost:5100/oidc/authorize');
49
+ expect(url.searchParams.get('client_id')).toBe('keycloak-broker');
50
+ expect(url.searchParams.get('login_hint')).toBe('synthetic-ticket-not-redeemed');
51
+ expect(url.searchParams.get('redirect_uri')).toBe(`${issuer}/broker/lca-api/endpoint`);
52
+ });
53
+ }
54
+ );