@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,108 @@
1
+ import { z } from 'zod';
2
+
3
+ export const keycloakUserSchema = z.object({
4
+ id: z.string(),
5
+ username: z.string().optional(),
6
+ email: z.string().optional(),
7
+ emailVerified: z.boolean().optional(),
8
+ enabled: z.boolean().optional(),
9
+ attributes: z.record(z.string(), z.array(z.string())).optional(),
10
+ });
11
+ export const federatedIdentitySchema = z.object({
12
+ identityProvider: z.string(),
13
+ userId: z.string(),
14
+ userName: z.string(),
15
+ });
16
+ export type KeycloakUser = z.infer<typeof keycloakUserSchema>;
17
+ export type FederatedIdentity = z.infer<typeof federatedIdentitySchema>;
18
+
19
+ export class KeycloakAdminError extends Error {
20
+ constructor(public readonly status: number) {
21
+ super(`Admin operation failed (${status})`);
22
+ this.name = 'KeycloakAdminError';
23
+ }
24
+ }
25
+
26
+ /** Minimal admin client; no tokens or response bodies are included in errors. */
27
+ export const createKeycloakAdmin = async (
28
+ fetchRequest: typeof fetch = fetch,
29
+ now: () => number = Date.now
30
+ ): Promise<{
31
+ request: (path: string, init?: RequestInit) => Promise<Response>;
32
+ findUsers: (email: string) => Promise<KeycloakUser[]>;
33
+ links: (id: string) => Promise<FederatedIdentity[]>;
34
+ }> => {
35
+ const origin = process.env.KEYCLOAK_ADMIN_URL ?? 'http://localhost:8081';
36
+ const realm = process.env.KEYCLOAK_ADMIN_REALM ?? 'master';
37
+ const targetRealm = process.env.KEYCLOAK_REALM ?? 'learncard';
38
+ const clientId = process.env.KEYCLOAK_ADMIN_CLIENT_ID ?? 'admin-cli';
39
+ const secret = process.env.KEYCLOAK_ADMIN_CLIENT_SECRET;
40
+ const username = process.env.KEYCLOAK_ADMIN_USERNAME;
41
+ const password = process.env.KEYCLOAK_ADMIN_PASSWORD;
42
+ if (!secret && (!username || !password)) throw new Error('Admin credentials are required');
43
+ let token = '';
44
+ let refreshAt = 0;
45
+ const refreshToken = async (): Promise<void> => {
46
+ const startedAt = now();
47
+ const tokenResponse = await fetchRequest(
48
+ `${origin}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/token`,
49
+ {
50
+ method: 'POST',
51
+ redirect: 'error',
52
+ signal: AbortSignal.timeout(15_000),
53
+ body: new URLSearchParams({
54
+ client_id: clientId,
55
+ ...(secret
56
+ ? { grant_type: 'client_credentials', client_secret: secret }
57
+ : { grant_type: 'password', username: username!, password: password! }),
58
+ }),
59
+ }
60
+ );
61
+ if (!tokenResponse.ok)
62
+ throw new Error(`Admin authentication failed (${tokenResponse.status})`);
63
+ const result = z
64
+ .object({ access_token: z.string().min(1), expires_in: z.number().positive() })
65
+ .parse(await tokenResponse.json());
66
+ token = result.access_token;
67
+ const lifetime = result.expires_in * 1000;
68
+ refreshAt = startedAt + lifetime - Math.min(30_000, lifetime / 10);
69
+ };
70
+ await refreshToken();
71
+ const request = async (path: string, init: RequestInit = {}): Promise<Response> => {
72
+ if (now() >= refreshAt) await refreshToken();
73
+ const send = async (): Promise<Response> =>
74
+ fetchRequest(`${origin}/admin/realms/${encodeURIComponent(targetRealm)}${path}`, {
75
+ ...init,
76
+ redirect: 'error',
77
+ signal: AbortSignal.timeout(15_000),
78
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
79
+ });
80
+ let response = await send();
81
+ if (response.status === 401) {
82
+ await response.body?.cancel();
83
+ await refreshToken();
84
+ response = await send();
85
+ }
86
+ if (!response.ok) throw new KeycloakAdminError(response.status);
87
+ return response;
88
+ };
89
+ return {
90
+ request,
91
+ findUsers: async (email: string): Promise<KeycloakUser[]> =>
92
+ z
93
+ .array(keycloakUserSchema)
94
+ .parse(
95
+ await (
96
+ await request(`/users?${new URLSearchParams({ email, exact: 'true' })}`)
97
+ ).json()
98
+ ),
99
+ links: async (id: string): Promise<FederatedIdentity[]> =>
100
+ z
101
+ .array(federatedIdentitySchema)
102
+ .parse(
103
+ await (
104
+ await request(`/users/${encodeURIComponent(id)}/federated-identity`)
105
+ ).json()
106
+ ),
107
+ };
108
+ };
@@ -0,0 +1,147 @@
1
+ import { setTimeout } from 'node:timers/promises';
2
+ import { KeycloakAdminError, type createKeycloakAdmin } from './keycloak-admin';
3
+
4
+ export interface FirebaseProvider {
5
+ providerId: string;
6
+ uid: string;
7
+ email?: string;
8
+ }
9
+
10
+ export interface FirebaseUser {
11
+ uid: string;
12
+ disabled: boolean;
13
+ emailVerified: boolean;
14
+ email?: string;
15
+ providerData: FirebaseProvider[];
16
+ }
17
+
18
+ export interface FirebaseReader {
19
+ getUsers: (identifiers: { uid: string }[]) => Promise<{ users: FirebaseUser[] }>;
20
+ }
21
+
22
+ export interface ProviderLinkSummary {
23
+ linked: number;
24
+ alreadyLinked: number;
25
+ conflicts: number;
26
+ noSocialProvider: number;
27
+ wouldLink: number;
28
+ }
29
+
30
+ /** Firebase accepts at most 100 identifiers per call; keep requests sequential and paced. */
31
+ export const fetchFirebaseUsers = async (
32
+ uids: string[],
33
+ firebase: FirebaseReader,
34
+ sleep: (ms: number) => Promise<void> = setTimeout
35
+ ): Promise<Map<string, FirebaseUser>> => {
36
+ const unique = [...new Set(uids)];
37
+ const users = new Map<string, FirebaseUser>();
38
+ for (let offset = 0; offset < unique.length; offset += 100) {
39
+ if (offset) await sleep(1000);
40
+ const identifiers = unique.slice(offset, offset + 100).map(uid => ({ uid }));
41
+ for (let attempt = 0; ; attempt++) {
42
+ try {
43
+ const result = await firebase.getUsers(identifiers);
44
+ for (const user of result.users) users.set(user.uid, user);
45
+ break;
46
+ } catch (error) {
47
+ const code =
48
+ typeof error === 'object' && error !== null && 'code' in error
49
+ ? error.code
50
+ : undefined;
51
+ if (
52
+ attempt >= 3 ||
53
+ (code !== 'auth/too-many-requests' && code !== 'auth/quota-exceeded')
54
+ ) {
55
+ throw error;
56
+ }
57
+ await sleep(1000 * 2 ** attempt);
58
+ }
59
+ }
60
+ }
61
+ return users;
62
+ };
63
+
64
+ /** Read-only preflight: reject the whole user before adding any provider links. */
65
+ export const findProviderLinkConflicts = (
66
+ providers: FirebaseProvider[],
67
+ links: { identityProvider: string; userId: string }[]
68
+ ): string[] =>
69
+ ['google', 'apple'].filter(alias => {
70
+ const matches = providers.filter(provider => provider.providerId === `${alias}.com`);
71
+ if (!matches.length) return false;
72
+ const existing = links.find(link => link.identityProvider === alias);
73
+ return (
74
+ matches.some(provider => !provider.uid) ||
75
+ new Set(matches.map(provider => provider.uid)).size > 1 ||
76
+ Boolean(existing && existing.userId !== matches[0]!.uid)
77
+ );
78
+ });
79
+
80
+ /** Only add identities; never remove or reassign a provider link, even on conflicts. */
81
+ export const linkFirebaseProviders = async ({
82
+ userId,
83
+ providers,
84
+ apply,
85
+ admin,
86
+ log,
87
+ summary = { linked: 0, alreadyLinked: 0, conflicts: 0, noSocialProvider: 0, wouldLink: 0 },
88
+ }: {
89
+ userId?: string;
90
+ providers: FirebaseProvider[];
91
+ apply: boolean;
92
+ admin: Pick<Awaited<ReturnType<typeof createKeycloakAdmin>>, 'links' | 'request'>;
93
+ log: (message: string) => void;
94
+ summary?: ProviderLinkSummary;
95
+ }): Promise<ProviderLinkSummary> => {
96
+ const social = providers.filter(
97
+ provider => provider.providerId === 'google.com' || provider.providerId === 'apple.com'
98
+ );
99
+ if (!social.length) {
100
+ summary.noSocialProvider++;
101
+ return summary;
102
+ }
103
+ const links = userId ? await admin.links(userId) : [];
104
+ const conflicts = findProviderLinkConflicts(providers, links);
105
+ if (conflicts.length) {
106
+ summary.conflicts += conflicts.length;
107
+ for (const alias of conflicts)
108
+ log(`CONFLICT: ${alias} identity differs; manual review required`);
109
+ return summary;
110
+ }
111
+ for (const alias of ['google', 'apple']) {
112
+ const matches = social.filter(provider => provider.providerId === `${alias}.com`);
113
+ if (!matches.length) continue;
114
+ const provider = matches[0]!;
115
+ const existing = links.find(link => link.identityProvider === alias);
116
+ if (existing) {
117
+ summary.alreadyLinked++;
118
+ continue;
119
+ }
120
+ if (!apply) {
121
+ summary.wouldLink++;
122
+ log(`PLAN: link ${alias} (ownership conflicts checked on apply)`);
123
+ continue;
124
+ }
125
+ if (!userId) throw new Error('A provisioned Keycloak user is required for linking');
126
+ try {
127
+ await admin.request(
128
+ `/users/${encodeURIComponent(userId)}/federated-identity/${alias}`,
129
+ {
130
+ method: 'POST',
131
+ body: JSON.stringify({
132
+ identityProvider: alias,
133
+ userId: provider.uid,
134
+ userName: provider.email || provider.uid,
135
+ }),
136
+ }
137
+ );
138
+ summary.linked++;
139
+ log(`Linked ${alias}`);
140
+ } catch (error) {
141
+ if (!(error instanceof KeycloakAdminError) || error.status !== 409) throw error;
142
+ summary.conflicts++;
143
+ log(`CONFLICT: ${alias} identity already linked; manual review required`);
144
+ }
145
+ }
146
+ return summary;
147
+ };
@@ -0,0 +1,21 @@
1
+ // Prints the signing-authority seed rollout flags a deployment loads from its checked-in
2
+ // config.<tenant>.<stage>.json. Usage: CONFIG_TENANT=scouts bun run sa-seed:flags production
3
+ import { resolveStageDefaults } from '@learncard/service-config';
4
+
5
+ import { base, stages } from '../src/config/stageConfig';
6
+
7
+ const stage = process.argv[2];
8
+ if (!stage)
9
+ throw new Error(
10
+ 'Usage: bun run sa-seed:flags <dev|production> (CONFIG_TENANT selects the tenant)'
11
+ );
12
+
13
+ const resolved = resolveStageDefaults({ base, stages, stage, env: process.env });
14
+ console.log(
15
+ JSON.stringify({
16
+ tenant: process.env.CONFIG_TENANT || 'learncard',
17
+ stage,
18
+ SA_SEED_ENCRYPT_WRITES: resolved.SA_SEED_ENCRYPT_WRITES ?? '(schema default)',
19
+ SA_SEED_ALLOW_LEGACY_READ: resolved.SA_SEED_ALLOW_LEGACY_READ ?? '(schema default)',
20
+ })
21
+ );