@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,480 @@
1
+ import { resolveTenantFromRequest } from '@learncard/email-templates';
2
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { createLocalJWKSet, exportJWK, generateKeyPair, SignJWT, type JWTPayload } from 'jose';
4
+ import { authRouter } from '../src/routes/auth';
5
+ import { setSocialJwksResolverForTests } from '../src/helpers/social-token.helpers';
6
+ import type { MongoAuthSubjectType } from '../src/models/AuthSubject';
7
+ import { getOrCreateAuthSubject } from '../src/models/AuthSubject';
8
+ import cache from '@cache';
9
+
10
+ const { env, store, subjects, mongo, expiries } = vi.hoisted(() => ({
11
+ env: { GOOGLE_OAUTH_CLIENT_IDS: 'google-client', APPLE_OAUTH_CLIENT_IDS: 'apple-client' },
12
+ store: new Map<string, string>(),
13
+ expiries: new Map<string, number>(),
14
+ subjects: new Map<string, MongoAuthSubjectType>(),
15
+ mongo: { failNextUpsert: false },
16
+ }));
17
+ vi.mock('@environment', () => ({ environment: env }));
18
+ vi.mock('@routes', async () => {
19
+ const { initTRPC } = await import('@trpc/server');
20
+ const t = initTRPC.context<{ clientIp: string }>().meta<{ openapi: unknown }>().create();
21
+ return { t, openRoute: t.procedure };
22
+ });
23
+ vi.mock('@mongo', () => ({
24
+ default: {
25
+ collection: () => ({
26
+ createIndex: vi.fn(async () => 'index'),
27
+ indexes: vi.fn(async () => []),
28
+ dropIndex: vi.fn(async () => undefined),
29
+ findOne: vi.fn(
30
+ async (filter: { identityKey: string }) => subjects.get(filter.identityKey) ?? null
31
+ ),
32
+ findOneAndUpdate: vi.fn(
33
+ async (
34
+ filter: { identityKey: string },
35
+ update: {
36
+ $setOnInsert: MongoAuthSubjectType;
37
+ $set: Partial<MongoAuthSubjectType>;
38
+ }
39
+ ) => {
40
+ if (mongo.failNextUpsert) {
41
+ mongo.failNextUpsert = false;
42
+ throw new Error('mongo unavailable');
43
+ }
44
+ const record = {
45
+ ...(subjects.get(filter.identityKey) ?? update.$setOnInsert),
46
+ ...update.$set,
47
+ };
48
+ subjects.set(filter.identityKey, record);
49
+ return record;
50
+ }
51
+ ),
52
+ }),
53
+ },
54
+ }));
55
+ vi.mock('@cache', () => ({
56
+ default: {
57
+ get: async (key: string) => store.get(key),
58
+ set: async (key: string, value: string) => {
59
+ store.set(key, value);
60
+ return 'OK';
61
+ },
62
+ delete: async (keys: string[]) => {
63
+ keys.forEach(key => store.delete(key));
64
+ },
65
+ node: {
66
+ eval: async (script: string, _count: number, key: string, code: string | number) => {
67
+ if (script.includes("redis.call('TTL'")) {
68
+ if (script.includes("redis.call('INCR'")) {
69
+ store.set(key, String(Number(store.get(key) ?? 0) + 1));
70
+ }
71
+ if (store.has(key) && !expiries.has(key)) expiries.set(key, Number(code));
72
+ return store.get(key) ?? null;
73
+ }
74
+ if (store.get(key) !== code) return 0;
75
+ store.delete(key);
76
+ return 1;
77
+ },
78
+ getdel: async (key: string) => {
79
+ const value = store.get(key) ?? null;
80
+ store.delete(key);
81
+ return value;
82
+ },
83
+ incr: async (key: string) => {
84
+ const count = (Number(store.get(key)) || 0) + 1;
85
+ store.set(key, String(count));
86
+ return count;
87
+ },
88
+ expire: vi.fn(async () => 1),
89
+ },
90
+ },
91
+ }));
92
+
93
+ let keys: Awaited<ReturnType<typeof generateKeyPair>>;
94
+ const caller = () =>
95
+ authRouter.createCaller({
96
+ clientIp: 'test-ip',
97
+ domain: 'localhost',
98
+ tenant: resolveTenantFromRequest({}),
99
+ });
100
+ const sign = (claims: JWTPayload = {}) =>
101
+ new SignJWT({
102
+ sub: 'social-sub',
103
+ iss: 'https://accounts.google.com',
104
+ aud: 'google-client',
105
+ email: 'test@example.com',
106
+ email_verified: true,
107
+ exp: Math.floor(Date.now() / 1000) + 300,
108
+ ...claims,
109
+ })
110
+ .setProtectedHeader({ alg: 'RS256', kid: 'social-key' })
111
+ .sign(keys.privateKey);
112
+ const payload = (ticket: string | undefined): Record<string, unknown> =>
113
+ JSON.parse(store.get(`login-ticket:${ticket}`)!);
114
+ const emailLogin = async (email = 'test@example.com') => {
115
+ store.set(`login-code:${email.trim()}`, '123456');
116
+ return caller().requestLoginTicket({ email, code: '123456' });
117
+ };
118
+ beforeAll(async () => {
119
+ keys = await generateKeyPair('RS256');
120
+ const jwks = createLocalJWKSet({
121
+ keys: [{ ...(await exportJWK(keys.publicKey)), kid: 'social-key' }],
122
+ });
123
+ setSocialJwksResolverForTests(() => jwks);
124
+ });
125
+ beforeEach(() => {
126
+ store.clear();
127
+ expiries.clear();
128
+ subjects.clear();
129
+ mongo.failNextUpsert = false;
130
+ vi.spyOn(console, 'error').mockImplementation(() => undefined);
131
+ env.GOOGLE_OAUTH_CLIENT_IDS = 'google-client';
132
+ env.APPLE_OAUTH_CLIENT_IDS = 'apple-client';
133
+ });
134
+ afterEach(() => {
135
+ vi.restoreAllMocks();
136
+ });
137
+
138
+ describe('auth login tickets', () => {
139
+ it('consumes a valid code and issues a random ticket', async () => {
140
+ const result = await emailLogin();
141
+ expect(result.success).toBe(true);
142
+ expect(result.ticket).toMatch(/^[A-Za-z0-9_-]{43}$/);
143
+ expect(store.has('login-code:test@example.com')).toBe(false);
144
+ expect(payload(result.ticket)).toMatchObject({
145
+ identityKey: 'email:test@example.com',
146
+ emailVerified: true,
147
+ });
148
+ });
149
+ it('returns a safe failure for invalid codes', async () => {
150
+ expect(
151
+ await caller().requestLoginTicket({ email: 'test@example.com', code: '000000' })
152
+ ).toEqual({ success: false, error: 'Invalid or expired code.' });
153
+ });
154
+ it('returns no ticket when the cache swallows a failed email ticket write', async () => {
155
+ vi.spyOn(cache, 'set').mockResolvedValueOnce(undefined);
156
+ expect(await emailLogin()).toEqual({
157
+ success: false,
158
+ error: 'Something went wrong. Please request a new code.',
159
+ });
160
+ expect([...store.keys()].some(key => key.startsWith('login-ticket:'))).toBe(false);
161
+ });
162
+ it('returns no ticket when a social ticket write fails', async () => {
163
+ vi.spyOn(cache, 'set').mockResolvedValueOnce(undefined);
164
+ const result = await caller().requestSocialLoginTicket({
165
+ provider: 'google',
166
+ idToken: await sign(),
167
+ });
168
+ expect(result.success).toBe(false);
169
+ expect(result.ticket).toBeUndefined();
170
+ expect([...store.keys()].some(key => key.startsWith('login-ticket:'))).toBe(false);
171
+ });
172
+ it('rejects a wrong code without consuming the valid code', async () => {
173
+ store.set('login-code:test@example.com', '123456');
174
+ expect(
175
+ (await caller().requestLoginTicket({ email: 'test@example.com', code: '000000' }))
176
+ .success
177
+ ).toBe(false);
178
+ expect(store.get('login-code:test@example.com')).toBe('123456');
179
+ expect(
180
+ (await caller().requestLoginTicket({ email: 'test@example.com', code: '123456' }))
181
+ .success
182
+ ).toBe(true);
183
+ });
184
+ it('rejects code replay', async () => {
185
+ await emailLogin();
186
+ expect(
187
+ (await caller().requestLoginTicket({ email: 'test@example.com', code: '123456' }))
188
+ .success
189
+ ).toBe(false);
190
+ });
191
+ it('reuses the subject for the same email', async () => {
192
+ const first = payload((await emailLogin()).ticket);
193
+ const second = payload((await emailLogin()).ticket);
194
+ expect(first.subject).toBe(second.subject);
195
+ expect(first.subject).toMatch(/^[0-9a-f-]{14}4[0-9a-f-]{21}$/);
196
+ });
197
+ it('normalizes case/whitespace for identity, not the original code lookup', async () => {
198
+ const first = payload((await emailLogin()).ticket);
199
+ const second = payload((await emailLogin(' Test@Example.com ')).ticket);
200
+ expect(second.identityKey).toBe('email:test@example.com');
201
+ expect(second.subject).toBe(first.subject);
202
+ });
203
+ it('does not accept a differently-cased code cache key', async () => {
204
+ store.set('login-code:test@example.com', '123456');
205
+ expect(
206
+ (await caller().requestLoginTicket({ email: 'Test@Example.com', code: '123456' }))
207
+ .success
208
+ ).toBe(false);
209
+ });
210
+ it('creates different UUIDs for different identities', async () => {
211
+ expect(payload((await emailLogin()).ticket).subject).not.toBe(
212
+ payload((await emailLogin('other@example.com')).ticket).subject
213
+ );
214
+ });
215
+ it('accepts Google and keys it by Google subject, not email', async () => {
216
+ const result = await caller().requestSocialLoginTicket({
217
+ provider: 'google',
218
+ idToken: await sign({ name: 'Name', picture: 'https://p.test' }),
219
+ });
220
+ expect(result.success).toBe(true);
221
+ expect(payload(result.ticket)).toMatchObject({
222
+ identityKey: 'google:social-sub',
223
+ name: 'Name',
224
+ picture: 'https://p.test',
225
+ });
226
+ expect(subjects.get('google:social-sub')).toMatchObject({
227
+ displayName: 'Name',
228
+ pictureUrl: 'https://p.test',
229
+ });
230
+ });
231
+ it.each([
232
+ { email_verified: false },
233
+ { email_verified: 'true' },
234
+ { aud: 'wrong' },
235
+ { iss: 'https://wrong.test' },
236
+ { email: undefined },
237
+ { sub: '' },
238
+ { exp: 1 },
239
+ ])('rejects invalid Google proof %j', async claims => {
240
+ const result = await caller().requestSocialLoginTicket({
241
+ provider: 'google',
242
+ idToken: await sign(claims),
243
+ });
244
+ expect(result.success).toBe(false);
245
+ expect(result.ticket).toBeUndefined();
246
+ expect(subjects.size).toBe(0);
247
+ });
248
+ it('accepts Apple string true and relay addresses', async () => {
249
+ const result = await caller().requestSocialLoginTicket({
250
+ provider: 'apple',
251
+ idToken: await sign({
252
+ iss: 'https://appleid.apple.com',
253
+ aud: 'apple-client',
254
+ email_verified: 'true',
255
+ email: 'x@privaterelay.appleid.com',
256
+ }),
257
+ });
258
+ expect(result.success).toBe(true);
259
+ expect(payload(result.ticket).identityKey).toBe('apple:social-sub');
260
+ });
261
+ it('returns a friendly unconfigured-provider error', async () => {
262
+ env.GOOGLE_OAUTH_CLIENT_IDS = '';
263
+ expect(
264
+ await caller().requestSocialLoginTicket({ provider: 'google', idToken: 'unused' })
265
+ ).toEqual({ success: false, error: 'Sign-in with google is not configured.' });
266
+ });
267
+ it('keeps separate subjects when the social email differs from the existing email identity', async () => {
268
+ const first = payload((await emailLogin()).ticket);
269
+ const second = payload(
270
+ (
271
+ await caller().requestSocialLoginTicket({
272
+ provider: 'google',
273
+ idToken: await sign({ email: 'different@example.com' }),
274
+ })
275
+ ).ticket
276
+ );
277
+ expect(first.subject).not.toBe(second.subject);
278
+ });
279
+ it('unifies a native social sign-in with an existing email-code identity sharing the same verified email', async () => {
280
+ const first = payload((await emailLogin()).ticket);
281
+ const second = payload(
282
+ (await caller().requestSocialLoginTicket({ provider: 'google', idToken: await sign() }))
283
+ .ticket
284
+ );
285
+ expect(second.subject).toBe(first.subject);
286
+ expect(subjects.get('google:social-sub')).toMatchObject({ subject: first.subject });
287
+ expect(subjects.get('email:test@example.com')).toMatchObject({ subject: first.subject });
288
+ });
289
+ it('normalizes case/whitespace when matching the social email to an existing identity', async () => {
290
+ const first = payload((await emailLogin('Test@Example.com')).ticket);
291
+ const second = payload(
292
+ (
293
+ await caller().requestSocialLoginTicket({
294
+ provider: 'google',
295
+ idToken: await sign({ email: ' Test@Example.com ' }),
296
+ })
297
+ ).ticket
298
+ );
299
+ expect(second.subject).toBe(first.subject);
300
+ });
301
+ it('unifies Apple (string "true" email_verified) with an existing email identity too', async () => {
302
+ const first = payload((await emailLogin()).ticket);
303
+ const second = payload(
304
+ (
305
+ await caller().requestSocialLoginTicket({
306
+ provider: 'apple',
307
+ idToken: await sign({
308
+ iss: 'https://appleid.apple.com',
309
+ aud: 'apple-client',
310
+ email_verified: 'true',
311
+ }),
312
+ })
313
+ ).ticket
314
+ );
315
+ expect(second.subject).toBe(first.subject);
316
+ });
317
+ it('unifies native Google first, then email code', async () => {
318
+ const socialFirst = payload(
319
+ (await caller().requestSocialLoginTicket({ provider: 'google', idToken: await sign() }))
320
+ .ticket
321
+ );
322
+ const emailSecond = payload((await emailLogin()).ticket);
323
+ expect(emailSecond.subject).toBe(socialFirst.subject);
324
+ const socialAgain = payload(
325
+ (await caller().requestSocialLoginTicket({ provider: 'google', idToken: await sign() }))
326
+ .ticket
327
+ );
328
+ expect(socialAgain.subject).toBe(socialFirst.subject);
329
+ });
330
+ it('unifies native Google then native Apple with the same verified email', async () => {
331
+ const google = payload(
332
+ (await caller().requestSocialLoginTicket({ provider: 'google', idToken: await sign() }))
333
+ .ticket
334
+ );
335
+ const apple = payload(
336
+ (
337
+ await caller().requestSocialLoginTicket({
338
+ provider: 'apple',
339
+ idToken: await sign({ iss: 'https://appleid.apple.com', aud: 'apple-client' }),
340
+ })
341
+ ).ticket
342
+ );
343
+ expect(apple.subject).toBe(google.subject);
344
+ expect(subjects.get('email:test@example.com')?.subject).toBe(google.subject);
345
+ });
346
+ it('does not link an unverified social email to an existing email identity', async () => {
347
+ const email = payload((await emailLogin()).ticket);
348
+ const result = await caller().requestSocialLoginTicket({
349
+ provider: 'google',
350
+ idToken: await sign({ email_verified: false }),
351
+ });
352
+ expect(result.success).toBe(false);
353
+ expect(result.ticket).toBeUndefined();
354
+ expect(subjects.has('google:social-sub')).toBe(false);
355
+ expect(subjects.get('email:test@example.com')?.subject).toBe(email.subject);
356
+ expect(subjects.size).toBe(1);
357
+ });
358
+ it('backfills the normalized email key for a legacy social identity', async () => {
359
+ const legacy = await getOrCreateAuthSubject('google:social-sub', { emailVerified: true });
360
+ const social = payload(
361
+ (
362
+ await caller().requestSocialLoginTicket({
363
+ provider: 'google',
364
+ idToken: await sign({ email: ' Test@Example.com ' }),
365
+ })
366
+ ).ticket
367
+ );
368
+ expect(social.subject).toBe(legacy.subject);
369
+ expect(subjects.get('email:test@example.com')).toMatchObject({
370
+ subject: legacy.subject,
371
+ email: 'test@example.com',
372
+ emailVerified: true,
373
+ });
374
+ expect(payload((await emailLogin()).ticket).subject).toBe(legacy.subject);
375
+ });
376
+ it('preserves conflicting legacy subjects and warns without PII', async () => {
377
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
378
+ const legacy = await getOrCreateAuthSubject('google:social-sub', { emailVerified: true });
379
+ const email = payload((await emailLogin()).ticket);
380
+ expect(email.subject).not.toBe(legacy.subject);
381
+ const social = payload(
382
+ (
383
+ await caller().requestSocialLoginTicket({
384
+ provider: 'google',
385
+ idToken: await sign(),
386
+ })
387
+ ).ticket
388
+ );
389
+ expect(social.subject).toBe(legacy.subject);
390
+ expect(subjects.get('email:test@example.com')?.subject).toBe(email.subject);
391
+ expect(warn).toHaveBeenCalledExactlyOnceWith(
392
+ 'Social and email authentication subjects conflict; preserving both subjects.'
393
+ );
394
+ });
395
+ it('returns a distinct server error and logs when ticket issuance fails after the code is consumed', async () => {
396
+ mongo.failNextUpsert = true;
397
+ const result = await emailLogin();
398
+ expect(result).toEqual({
399
+ success: false,
400
+ error: 'Something went wrong. Please request a new code.',
401
+ });
402
+ expect(store.has('login-code:test@example.com')).toBe(false);
403
+ expect(console.error).toHaveBeenCalledWith(
404
+ 'Error issuing login ticket after consuming code:',
405
+ expect.any(Error)
406
+ );
407
+ expect(store.get('oidc:rate:email:test@example.com')).toBeUndefined();
408
+ });
409
+ it('does not log expected user errors', async () => {
410
+ await caller().requestLoginTicket({ email: 'test@example.com', code: '000000' });
411
+ await caller().requestSocialLoginTicket({
412
+ provider: 'google',
413
+ idToken: await sign({ aud: 'wrong' }),
414
+ });
415
+ expect(console.error).not.toHaveBeenCalled();
416
+ });
417
+
418
+ describe('rate limiting', () => {
419
+ const failEmail = (email = 'test@example.com') =>
420
+ caller().requestLoginTicket({ email, code: '000000' });
421
+ const failSocial = async () =>
422
+ caller().requestSocialLoginTicket({
423
+ provider: 'google',
424
+ idToken: await sign({ aud: 'wrong' }),
425
+ });
426
+
427
+ it('does not count successful logins against the per-IP limit', async () => {
428
+ for (let index = 0; index < 60; index++) {
429
+ expect((await emailLogin(`user${index}@example.com`)).success).toBe(true);
430
+ }
431
+ expect(store.get('oidc:rate:email-login-ticket:test-ip')).toBeUndefined();
432
+ });
433
+ it('limits failed email attempts per email to 5 per window', async () => {
434
+ for (let index = 0; index < 5; index++) {
435
+ expect((await failEmail()).success).toBe(false);
436
+ }
437
+ await expect(emailLogin()).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
438
+ expect(store.has('login-code:test@example.com')).toBe(true);
439
+ expect((await emailLogin('other@example.com')).success).toBe(true);
440
+ });
441
+ it('keys the per-email limit on the normalized address', async () => {
442
+ for (let index = 0; index < 5; index++) await failEmail(' Test@Example.com ');
443
+ await expect(failEmail('test@example.com')).rejects.toMatchObject({
444
+ code: 'TOO_MANY_REQUESTS',
445
+ });
446
+ });
447
+ it('backstops failed email attempts per IP at 50 across distinct emails', async () => {
448
+ for (let index = 0; index < 50; index++) {
449
+ expect((await failEmail(`user${index}@example.com`)).success).toBe(false);
450
+ }
451
+ await expect(emailLogin('fresh@example.com')).rejects.toMatchObject({
452
+ code: 'TOO_MANY_REQUESTS',
453
+ });
454
+ });
455
+ it('backstops failed social attempts per IP at 50 and ignores successes', async () => {
456
+ for (let index = 0; index < 5; index++) {
457
+ expect(
458
+ (
459
+ await caller().requestSocialLoginTicket({
460
+ provider: 'google',
461
+ idToken: await sign(),
462
+ })
463
+ ).success
464
+ ).toBe(true);
465
+ }
466
+ for (let index = 0; index < 50; index++) {
467
+ expect((await failSocial()).success).toBe(false);
468
+ }
469
+ await expect(failSocial()).rejects.toMatchObject({ code: 'TOO_MANY_REQUESTS' });
470
+ expect(store.get('oidc:rate:social-login-ticket:test-ip')).toBe('50');
471
+ });
472
+ it('sets the window TTL on the first failure only', async () => {
473
+ await failEmail();
474
+ await failEmail();
475
+ expect(expiries.size).toBe(2);
476
+ expect(expiries.get('oidc:rate:email-login-ticket:test-ip')).toBe(600);
477
+ expect(expiries.get('oidc:rate:email:test@example.com')).toBe(600);
478
+ });
479
+ });
480
+ });
@@ -0,0 +1,181 @@
1
+ import { createHash, randomBytes, randomInt, randomUUID } from 'node:crypto';
2
+ import Redis from 'ioredis';
3
+ import { z } from 'zod';
4
+
5
+ export const roundtripEnabled =
6
+ process.env.KEYCLOAK_INTEGRATION === 'true' && process.env.KEYCLOAK_ROUNDTRIP === 'true';
7
+ export const apiOrigin = process.env.OIDC_ISSUER ?? 'http://localhost:5100';
8
+ export const keycloakIssuer =
9
+ process.env.KEYCLOAK_ISSUERS?.split(',')[0] ?? 'http://localhost:8081/realms/learncard';
10
+ export const callback = 'http://localhost:3000/login';
11
+
12
+ export const createBrokerAuthorization = (
13
+ idp: string,
14
+ loginHint?: string
15
+ ): {
16
+ url: URL;
17
+ verifier: string;
18
+ state: string;
19
+ } => {
20
+ const verifier = randomBytes(32).toString('base64url');
21
+ const state = randomUUID();
22
+ const params = new URLSearchParams({
23
+ client_id: 'learncard-app',
24
+ response_type: 'code',
25
+ scope: 'openid email profile phone',
26
+ redirect_uri: callback,
27
+ state,
28
+ code_challenge: createHash('sha256').update(verifier).digest('base64url'),
29
+ code_challenge_method: 'S256',
30
+ kc_idp_hint: idp,
31
+ ...(loginHint ? { login_hint: loginHint } : {}),
32
+ });
33
+ return {
34
+ url: new URL(`${keycloakIssuer}/protocol/openid-connect/auth?${params}`),
35
+ verifier,
36
+ state,
37
+ };
38
+ };
39
+
40
+ export const exchangeBrokerCode = async (code: string, verifier: string): Promise<string> => {
41
+ const response = await fetch(`${keycloakIssuer}/protocol/openid-connect/token`, {
42
+ method: 'POST',
43
+ signal: AbortSignal.timeout(15_000),
44
+ body: new URLSearchParams({
45
+ grant_type: 'authorization_code',
46
+ client_id: 'learncard-app',
47
+ code,
48
+ redirect_uri: callback,
49
+ code_verifier: verifier,
50
+ }),
51
+ });
52
+ if (!response.ok) throw new Error(`PKCE exchange failed (${response.status})`);
53
+ return z.object({ id_token: z.string() }).parse(await response.json()).id_token;
54
+ };
55
+
56
+ export const trpc = async (route: string, input: unknown): Promise<unknown> => {
57
+ const response = await fetch(`${apiOrigin}/trpc/${route}`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json', 'X-Tenant-Id': 'learncard' },
60
+ body: JSON.stringify(input),
61
+ signal: AbortSignal.timeout(15_000),
62
+ });
63
+ if (!response.ok) throw new Error(`tRPC ${route}: HTTP ${response.status}`);
64
+ const envelope = z
65
+ .object({ result: z.object({ data: z.unknown() }) })
66
+ .parse(await response.json());
67
+ return envelope.result.data;
68
+ };
69
+
70
+ interface Cookie {
71
+ name: string;
72
+ value: string;
73
+ path: string;
74
+ origin: string;
75
+ secure: boolean;
76
+ }
77
+
78
+ /** Follow only the two configured origins, never logging tickets, codes or cookies. */
79
+ export const walkBrokerRedirects = async (
80
+ start: URL
81
+ ): Promise<{ code: string; trace: string[]; state: string }> => {
82
+ const cookies = new Map<string, Cookie>();
83
+ const trace: string[] = [];
84
+ let url = start;
85
+ for (let hop = 0; hop < 12; hop++) {
86
+ if (!new Set([new URL(keycloakIssuer).origin, new URL(apiOrigin).origin]).has(url.origin)) {
87
+ throw new Error('Unexpected broker redirect origin');
88
+ }
89
+ const cookie = [...cookies.values()]
90
+ .filter(
91
+ item =>
92
+ item.origin === url.origin &&
93
+ (url.pathname === item.path ||
94
+ url.pathname.startsWith(
95
+ item.path.endsWith('/') ? item.path : `${item.path}/`
96
+ )) &&
97
+ // Browsers allow Secure cookies on localhost (Keycloak emits them even in dev).
98
+ (!item.secure || url.protocol === 'https:' || url.hostname === 'localhost')
99
+ )
100
+ .map(item => `${item.name}=${item.value}`)
101
+ .join('; ');
102
+ const response = await fetch(url, {
103
+ redirect: 'manual',
104
+ headers: { cookie },
105
+ signal: AbortSignal.timeout(15_000),
106
+ });
107
+ for (const serialized of response.headers.getSetCookie()) {
108
+ const [pair = '', ...attributes] = serialized.split(';').map(value => value.trim());
109
+ const index = pair.indexOf('=');
110
+ const name = pair.slice(0, index);
111
+ const value = pair.slice(index + 1);
112
+ const path = attributes.find(attr => /^path=/i.test(attr))?.slice(5) || '/';
113
+ const key = `${url.origin}:${path}:${name}`;
114
+ if (attributes.some(attr => /^max-age=0$/i.test(attr))) cookies.delete(key);
115
+ else
116
+ cookies.set(key, {
117
+ name,
118
+ value,
119
+ path,
120
+ origin: url.origin,
121
+ secure: attributes.some(attr => /^secure$/i.test(attr)),
122
+ });
123
+ }
124
+ if (response.status < 300 || response.status >= 400) {
125
+ const title =
126
+ (await response.text()).match(/<title[^>]*>([^<]*)<\/title>/i)?.[1] ?? '(no title)';
127
+ throw new Error(
128
+ `Broker rendered HTTP ${response.status} at ${url.pathname}; title: ${title}; trace: ${trace.join(' → ')}`
129
+ );
130
+ }
131
+ const location = response.headers.get('location');
132
+ if (!location) throw new Error('Broker redirect lacks Location');
133
+ const next = new URL(location, url);
134
+ trace.push(
135
+ `${response.status} ${url.origin}${url.pathname} → ${next.origin}${next.pathname}`
136
+ );
137
+ await response.body?.cancel();
138
+ if (`${next.origin}${next.pathname}` === callback) {
139
+ const code = next.searchParams.get('code');
140
+ const state = next.searchParams.get('state');
141
+ if (!code || !state || next.searchParams.has('error'))
142
+ throw new Error('Broker callback lacks a successful code/state');
143
+ return { code, state, trace };
144
+ }
145
+ url = next;
146
+ }
147
+ throw new Error('Broker exceeded 12 redirect hops');
148
+ };
149
+
150
+ /** Real OTP ticket → broker → lca-api → broker callback → PKCE token exchange. */
151
+ export const signInThroughBroker = async (
152
+ email: string
153
+ ): Promise<{ idToken: string; trace: string[] }> => {
154
+ if (!process.env.REDIS_HOST || !process.env.REDIS_PORT)
155
+ throw new Error('Set live API REDIS_HOST/REDIS_PORT explicitly');
156
+ const redis = new Redis({
157
+ host: process.env.REDIS_HOST,
158
+ port: Number(process.env.REDIS_PORT),
159
+ maxRetriesPerRequest: 1,
160
+ });
161
+ const code = String(randomInt(1_000_000)).padStart(6, '0');
162
+ const key = `login-code:${email}`;
163
+ let ticket: string;
164
+ try {
165
+ await redis.set(key, code, 'EX', 60);
166
+ const result = z
167
+ .object({ success: z.literal(true), ticket: z.string() })
168
+ .parse(await trpc('auth.requestLoginTicket', { email, code }));
169
+ ticket = result.ticket;
170
+ if (await redis.exists(key)) throw new Error('Login code was not consumed');
171
+ } finally {
172
+ await redis.del(key);
173
+ await redis.quit();
174
+ }
175
+ const { url, verifier, state } = createBrokerAuthorization('lca-api', ticket);
176
+ const result = await walkBrokerRedirects(url);
177
+ if (result.state !== state) throw new Error('Broker state mismatch');
178
+ const idToken = await exchangeBrokerCode(result.code, verifier);
179
+ process.stdout.write(`Broker hop trace:\n${result.trace.join('\n')}\n`);
180
+ return { idToken, trace: result.trace };
181
+ };
@@ -0,0 +1,9 @@
1
+ import { MongoClient } from 'mongodb';
2
+
3
+ // Only selected by the explicitly opted-in broker integration configuration.
4
+ if (!process.env.MONGO_URI || !process.env.MONGO_DB_NAME) {
5
+ throw new Error('Live broker tests require MONGO_URI and MONGO_DB_NAME for the running API');
6
+ }
7
+ export const client = new MongoClient(process.env.MONGO_URI);
8
+ export const mongodb = client.db(process.env.MONGO_DB_NAME);
9
+ export default mongodb;