@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,73 @@
1
+ import type { FastifyRequest } from 'fastify';
2
+
3
+ import cache from '@cache';
4
+
5
+ export const DEFAULT_MAX_FAILED_ATTEMPTS = 50;
6
+ export const DEFAULT_RATE_LIMIT_WINDOW_SECONDS = 10 * 60;
7
+
8
+ // Repair legacy counters during reads too: an already-blocked caller never reaches recordFailure.
9
+ const READ_ATTEMPTS = `
10
+ local attempts = redis.call('GET', KEYS[1])
11
+ if attempts and redis.call('TTL', KEYS[1]) < 0 then
12
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
13
+ end
14
+ return attempts
15
+ `;
16
+
17
+ const RECORD_FAILURE = `
18
+ local attempts = redis.call('INCR', KEYS[1])
19
+ if redis.call('TTL', KEYS[1]) < 0 then
20
+ redis.call('EXPIRE', KEYS[1], ARGV[1])
21
+ end
22
+ return attempts
23
+ `;
24
+
25
+ /**
26
+ * Failures-only rate limiting. Callers check `isRateLimited` up front and call
27
+ * `recordFailure` only on rejected attempts, so legitimate traffic behind a
28
+ * shared NAT never counts toward the ceiling. Increment and TTL repair run in
29
+ * one script without extending an existing window.
30
+ *
31
+ * Both halves fail open: if Redis is unreachable, reads return false and
32
+ * `recordFailure` logs and returns
33
+ * rather than turning a rejected login attempt into a 500. Limiting is a
34
+ * brute-force speed bump, not the access control — that is the ticket / code /
35
+ * client secret entropy.
36
+ */
37
+ export const isRateLimited = async (
38
+ key: string,
39
+ max = DEFAULT_MAX_FAILED_ATTEMPTS
40
+ ): Promise<boolean> => {
41
+ const redis = cache.redis ?? cache.node;
42
+ try {
43
+ const attempts =
44
+ Number(await redis.eval(READ_ATTEMPTS, 1, key, DEFAULT_RATE_LIMIT_WINDOW_SECONDS)) || 0;
45
+ return attempts >= max;
46
+ } catch {
47
+ return false;
48
+ }
49
+ };
50
+
51
+ export const recordFailure = async (
52
+ key: string,
53
+ windowSeconds = DEFAULT_RATE_LIMIT_WINDOW_SECONDS
54
+ ): Promise<void> => {
55
+ const redis = cache.redis ?? cache.node;
56
+ try {
57
+ await redis.eval(RECORD_FAILURE, 1, key, windowSeconds);
58
+ } catch (error) {
59
+ console.error('Rate limit recordFailure error', error);
60
+ }
61
+ };
62
+
63
+ /**
64
+ * Client address for rate-limit keys.
65
+ *
66
+ * `request.ip` is the transport-level peer: under `serverless-http` it is API
67
+ * Gateway's `requestContext.http.sourceIp`, which the caller cannot forge. The
68
+ * `x-forwarded-for` header is NOT trusted — API Gateway appends the real source
69
+ * to whatever the client sent, so the first hop is attacker-controlled and would
70
+ * let anyone mint a fresh per-IP bucket per request.
71
+ */
72
+ export const getRequestClientIp = (request: Pick<FastifyRequest, 'ip'>): string =>
73
+ request.ip || 'unknown';
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Native Google / Apple `id_token` verification for the lca-api auth router
3
+ * (AD-2 / AD-3).
4
+ *
5
+ * The native app obtains an Apple/Google `id_token` through the platform SDK
6
+ * and posts it here. We verify it against the provider's published JWKS — exact
7
+ * issuer + audience (client id) allowlists, signature, `exp`/`nbf` — before
8
+ * issuing a login ticket. A test seam (`setSocialJwksResolverForTests`) swaps
9
+ * the remote JWKS for a local key set so the flow is unit-testable without
10
+ * network access.
11
+ */
12
+
13
+ import { environment } from '@environment';
14
+ import { TRPCError } from '@trpc/server';
15
+ import { createRemoteJWKSet, jwtVerify } from 'jose';
16
+ import type { JWTVerifyGetKey } from 'jose';
17
+ import { z } from 'zod';
18
+
19
+ export type SocialProviderId = 'google' | 'apple';
20
+
21
+ export interface SocialProviderConfig {
22
+ issuers: string[];
23
+ audiences: string[];
24
+ jwksUri: string;
25
+ }
26
+
27
+ export interface SocialClaims {
28
+ sub: string;
29
+ iss: string;
30
+ email?: string;
31
+ email_verified?: boolean;
32
+ name?: string;
33
+ picture?: string;
34
+ }
35
+
36
+ const GOOGLE_DEFAULT_ISSUERS = ['https://accounts.google.com', 'accounts.google.com'];
37
+ const GOOGLE_JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs';
38
+ const APPLE_DEFAULT_ISSUERS = ['https://appleid.apple.com'];
39
+ const APPLE_JWKS_URI = 'https://appleid.apple.com/auth/keys';
40
+
41
+ const claimsSchema = z
42
+ .object({
43
+ sub: z.string().min(1),
44
+ iss: z.string().min(1),
45
+ email: z.string().min(1).optional(),
46
+ email_verified: z.union([z.boolean(), z.string()]).optional(),
47
+ name: z.string().optional(),
48
+ picture: z.string().optional(),
49
+ })
50
+ .passthrough();
51
+
52
+ const splitCsv = (value: string | undefined): string[] =>
53
+ (value ?? '')
54
+ .split(',')
55
+ .map(entry => entry.trim())
56
+ .filter(Boolean);
57
+
58
+ export const getSocialProviderConfig = (
59
+ provider: SocialProviderId
60
+ ): SocialProviderConfig | null => {
61
+ if (provider === 'google') {
62
+ const audiences = splitCsv(environment.GOOGLE_OAUTH_CLIENT_IDS);
63
+ if (audiences.length === 0) return null;
64
+ return {
65
+ audiences,
66
+ issuers: GOOGLE_DEFAULT_ISSUERS,
67
+ jwksUri: GOOGLE_JWKS_URI,
68
+ };
69
+ }
70
+
71
+ const audiences = splitCsv(environment.APPLE_OAUTH_CLIENT_IDS);
72
+ if (audiences.length === 0) return null;
73
+ return {
74
+ audiences,
75
+ issuers: APPLE_DEFAULT_ISSUERS,
76
+ jwksUri: APPLE_JWKS_URI,
77
+ };
78
+ };
79
+
80
+ const jwksByUri = new Map<string, JWTVerifyGetKey>();
81
+ let resolverForTests: ((jwksUri: string) => JWTVerifyGetKey) | undefined;
82
+
83
+ export const setSocialJwksResolverForTests = (
84
+ resolver: ((jwksUri: string) => JWTVerifyGetKey) | undefined
85
+ ): void => {
86
+ resolverForTests = resolver;
87
+ jwksByUri.clear();
88
+ };
89
+
90
+ export const getSocialJwks = (jwksUri: string): JWTVerifyGetKey => {
91
+ let jwks = jwksByUri.get(jwksUri);
92
+ if (!jwks) {
93
+ jwks = resolverForTests ? resolverForTests(jwksUri) : createRemoteJWKSet(new URL(jwksUri));
94
+ jwksByUri.set(jwksUri, jwks);
95
+ }
96
+ return jwks;
97
+ };
98
+
99
+ export const verifySocialIdToken = async (
100
+ provider: SocialProviderId,
101
+ token: string
102
+ ): Promise<SocialClaims> => {
103
+ const config = getSocialProviderConfig(provider);
104
+ if (!config) {
105
+ throw new TRPCError({
106
+ code: 'UNAUTHORIZED',
107
+ message: `Sign-in with ${provider} is not configured.`,
108
+ });
109
+ }
110
+
111
+ let payload: unknown;
112
+ try {
113
+ ({ payload } = await jwtVerify(token, getSocialJwks(config.jwksUri), {
114
+ issuer: config.issuers,
115
+ audience: config.audiences,
116
+ algorithms: ['RS256', 'ES256'],
117
+ requiredClaims: ['sub', 'exp', 'email', 'email_verified'],
118
+ }));
119
+ } catch {
120
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
121
+ }
122
+
123
+ const result = claimsSchema.safeParse(payload);
124
+ if (!result.success) {
125
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
126
+ }
127
+
128
+ const claims = result.data;
129
+ const emailVerified =
130
+ claims.email_verified === true ||
131
+ (provider === 'apple' && claims.email_verified === 'true');
132
+ if (!claims.email || !emailVerified) {
133
+ throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid social token' });
134
+ }
135
+
136
+ return {
137
+ sub: claims.sub,
138
+ iss: claims.iss,
139
+ email: claims.email,
140
+ email_verified: emailVerified,
141
+ name: claims.name,
142
+ picture: claims.picture,
143
+ };
144
+ };
package/src/index.ts CHANGED
@@ -2,4 +2,5 @@ export type { AppRouter } from './app';
2
2
 
3
3
  export { appRouter as mainApp } from './app';
4
4
  export { app as didApp } from './dids';
5
+ export { app as oidcApp } from './oidc';
5
6
  export { app as swaggerApp } from './openapi';
@@ -0,0 +1,121 @@
1
+ import {
2
+ findPendingEscrowHoldByAuthProvider,
3
+ findUserKeysWithEscrowBlobKeyId,
4
+ rewrapEscrowBlobByAuthProvider,
5
+ } from '@models';
6
+ import {
7
+ getEnclaveAttestationIdentity,
8
+ getEscrowEnclave,
9
+ isEscrowEnabled,
10
+ } from '../services/escrow-enclave';
11
+
12
+ const DEFAULT_REWRAP_BATCH_LIMIT = 200;
13
+
14
+ export interface EscrowBlobRewrapResult {
15
+ rewrapped: number;
16
+ skippedPendingHold: number;
17
+ failed: number;
18
+ }
19
+
20
+ export interface RunEscrowBlobRewrapOptions {
21
+ limit?: number;
22
+ }
23
+
24
+ /**
25
+ * Deliberately drops the caught error's message, not just its stack: a real
26
+ * enclave/decrypt failure could embed plaintext or a proof in its own
27
+ * message text, so forwarding it here would leak secrets through the one
28
+ * logging path meant to report the failure. Mirrors
29
+ * `escrowHoldReminders.ts`'s `logJobError`, which makes the same trade-off
30
+ * for the same reason. `userKeyId` (a Mongo id, not a DID/email) is safe to
31
+ * log for triage.
32
+ */
33
+ const logJobError = (userKeyId: string | undefined, error: unknown): void => {
34
+ console.error('[escrow-blob-rewrap] failed to process user key', {
35
+ userKeyId,
36
+ errorType: error instanceof Error ? error.name : typeof error,
37
+ });
38
+ };
39
+
40
+ /**
41
+ * Scheduled maintenance job (P9.3): migrates escrow blobs still sealed
42
+ * under a previous enclave key onto the current key, so that key can
43
+ * eventually be retired. Covers accounts that never sign in again after a
44
+ * key rotation — those already migrate client-side on next login via P9.2's
45
+ * carry-on-enroll path.
46
+ *
47
+ * No-ops when escrow is disabled for this stage/tenant, or when the
48
+ * enclave's current (cached) identity reports no previous keys at all —
49
+ * the common case once every previous key has been fully retired. Skips
50
+ * (does not touch) any account with a pending recovery hold: the signed
51
+ * hold record binds the OLD blob's hash/epoch, so re-sealing mid-hold would
52
+ * break an in-progress recovery; those accounts are simply re-tried on a
53
+ * later run once their hold resolves. Every write is an atomic
54
+ * compare-and-swap (`rewrapEscrowBlobByAuthProvider`); losing that race to a
55
+ * concurrent re-enroll/carry/opt-out is a silent no-op, not a failure.
56
+ */
57
+ export const runEscrowBlobRewrap = async (
58
+ options: RunEscrowBlobRewrapOptions = {}
59
+ ): Promise<EscrowBlobRewrapResult> => {
60
+ const empty: EscrowBlobRewrapResult = { rewrapped: 0, skippedPendingHold: 0, failed: 0 };
61
+ if (!isEscrowEnabled()) return empty;
62
+
63
+ // Cheap, cache-aware gate: avoids a real enclave round trip on every
64
+ // run once there is nothing left to migrate (see services/escrow-enclave/
65
+ // index.ts's `getEnclaveAttestationIdentity`).
66
+ const identity = await getEnclaveAttestationIdentity().catch(() => undefined);
67
+ if (!identity || identity.previousKeyIds.length === 0) return empty;
68
+
69
+ const limit = options.limit ?? DEFAULT_REWRAP_BATCH_LIMIT;
70
+ const candidates = await findUserKeysWithEscrowBlobKeyId(identity.previousKeyIds, limit);
71
+ if (candidates.length === 0) return empty;
72
+
73
+ // A fresh, full attestation call (not just the cached identity) for the
74
+ // measurements the migrated blob must carry; only fetched once there is
75
+ // actually something to migrate this run.
76
+ const attestation = await getEscrowEnclave().getAttestation();
77
+
78
+ let rewrapped = 0;
79
+ let skippedPendingHold = 0;
80
+ let failed = 0;
81
+
82
+ for (const userKey of candidates) {
83
+ const blob = userKey.escrowBlob;
84
+ const authProvider = userKey.authProviders[0];
85
+ // Already migrated (by this loop's own attestation refresh landing
86
+ // mid-batch, or a concurrent run) since the candidate query ran.
87
+ if (!blob || !authProvider || blob.enclaveKeyId === attestation.keyId) continue;
88
+ try {
89
+ if (await findPendingEscrowHoldByAuthProvider(authProvider)) {
90
+ skippedPendingHold += 1;
91
+ continue;
92
+ }
93
+ const { envelope } = await getEscrowEnclave().rewrapEscrowBlob({
94
+ envelope: blob.envelope,
95
+ expectedDid: userKey.primaryDid,
96
+ expectedShareVersion: blob.shareVersion,
97
+ sourceEnrollmentEpoch: blob.enrollmentEpoch,
98
+ });
99
+ const stored = await rewrapEscrowBlobByAuthProvider(
100
+ authProvider,
101
+ blob,
102
+ userKey.escrowPin,
103
+ envelope,
104
+ {
105
+ enclaveKeyId: attestation.keyId,
106
+ enclaveMode: attestation.mode,
107
+ measurements: attestation.measurements,
108
+ }
109
+ );
110
+ if (stored) rewrapped += 1;
111
+ // else: lost the compare-and-swap race (a concurrent re-enroll,
112
+ // PIN change or opt-out since this candidate was read) — not a
113
+ // failure, just re-tried on a later run if still applicable.
114
+ } catch (error) {
115
+ failed += 1;
116
+ logJobError(userKey._id, error);
117
+ }
118
+ }
119
+
120
+ return { rewrapped, skippedPendingHold, failed };
121
+ };
@@ -0,0 +1,177 @@
1
+ import { resolveTenantFromRequest, type ResolvedTenant } from '@learncard/email-templates';
2
+ import {
3
+ expireStaleEscrowHolds,
4
+ findEscrowHoldsPendingEnclaveCancellation,
5
+ countEscrowCancellationsNearRelease,
6
+ findEscrowHoldsDueForReminder,
7
+ claimEscrowHoldForReminder,
8
+ findUserKeyByAuthProvider,
9
+ findEscrowHoldsPendingStartNotification,
10
+ releaseEscrowReminderClaim,
11
+ recordEscrowStartNotificationAttempt,
12
+ } from '@models';
13
+ import { environment } from '@environment';
14
+ import {
15
+ cancelHoldInEnclave,
16
+ logEscrowCancellationPending,
17
+ } from '../services/escrow-enclave/cancellation';
18
+ import { isEscrowEnabled, notifyEscrowHoldEvent } from '../services/escrow-enclave';
19
+
20
+ // Two queues of ten 3-second deliveries leave half the 120-second Lambda
21
+ // budget for database work. Least-recently-attempted ordering prevents starvation.
22
+ const DEFAULT_REMINDER_BATCH_LIMIT = 10;
23
+
24
+ export interface EscrowHoldReminderResult {
25
+ reminded: number;
26
+ expired: number;
27
+ failed: number;
28
+ }
29
+
30
+ export interface RunEscrowHoldRemindersOptions {
31
+ now?: Date;
32
+ limit?: number;
33
+ /**
34
+ * Test-only seam; defaults to the real `notifyEscrowHoldEvent`. Lets
35
+ * tests force a deterministic delivery failure without touching
36
+ * Postmark/FCM. Real callers (the Lambda handler) never pass this, so
37
+ * the effective public signature stays `{ now?, limit? }`.
38
+ */
39
+ notify?: typeof notifyEscrowHoldEvent;
40
+ }
41
+
42
+ /**
43
+ * Given a hold's stored `tenantId`, resolves the same `ResolvedTenant` shape
44
+ * route handlers get from `ctx.tenant`. A scheduled job has no request to
45
+ * read headers from, so this reuses `resolveTenantFromRequest`'s third
46
+ * resolution step directly (deploy-level id, same as `DEFAULT_TENANT_ID`)
47
+ * rather than duplicating the id → branding lookup it already does. Holds
48
+ * created before `tenantId` existed fall back to `DEFAULT_TENANT_ID` / the
49
+ * package's own 'learncard' default, exactly like any other unheadered call.
50
+ */
51
+ const resolveTenantById = (tenantId: string | undefined): ResolvedTenant =>
52
+ resolveTenantFromRequest({}, tenantId);
53
+
54
+ /**
55
+ * Deliberately drops the caught error's message, not just its stack: a real
56
+ * delivery-channel rejection (Postmark/FCM) can embed the recipient address
57
+ * in its own message text, so forwarding it here would leak PII through the
58
+ * one logging path meant to report the failure. Mirrors
59
+ * `notifications.ts`'s `logDeliveryFailure`, which makes the same trade-off
60
+ * for the same reason. The error's name/constructor is still useful for
61
+ * triage (e.g. "MongoServerError" vs "TypeError") without that risk.
62
+ */
63
+ const logJobError = (holdId: string, error: unknown): void => {
64
+ console.error('[escrow-hold-reminders] failed to process hold', {
65
+ holdId,
66
+ errorType: error instanceof Error ? error.name : typeof error,
67
+ });
68
+ };
69
+
70
+ /**
71
+ * Hourly maintenance job: expires stale pending holds, then sends a T-24h
72
+ * `reminder` notification to every pending, hold-policy escrow hold whose
73
+ * waiting period ends within 24h and that has never been reminded.
74
+ *
75
+ * Escrow can be entirely disabled (no `ESCROW_ENCLAVE_MODE`) on a given
76
+ * stage/tenant deploy. Rather than a Serverless-level `schedule.enabled`
77
+ * conditional (its value must be a literal boolean, not an interpolated env
78
+ * string, so gating it from `${env:ESCROW_ENCLAVE_MODE}` is fragile), the
79
+ * schedule stays always-enabled in serverless.yml and this job no-ops
80
+ * instead — the same disabled/enabled signal the escrow router itself uses
81
+ * via `getEscrowEnclave()` throwing when `isEscrowEnabled()` is false.
82
+ */
83
+ export const runEscrowHoldReminders = async (
84
+ options: RunEscrowHoldRemindersOptions = {}
85
+ ): Promise<EscrowHoldReminderResult> => {
86
+ const empty: EscrowHoldReminderResult = { reminded: 0, expired: 0, failed: 0 };
87
+ const now = options.now ?? new Date();
88
+ if (!isEscrowEnabled()) {
89
+ // Disabling recovery must not silence outstanding revocation alarms.
90
+ if (await countEscrowCancellationsNearRelease(now)) logEscrowCancellationPending(true);
91
+ return empty;
92
+ }
93
+ const limit = Math.max(
94
+ 1,
95
+ Math.min(options.limit ?? DEFAULT_REMINDER_BATCH_LIMIT, DEFAULT_REMINDER_BATCH_LIMIT)
96
+ );
97
+ const notify = options.notify ?? notifyEscrowHoldEvent;
98
+
99
+ const expired = await expireStaleEscrowHolds(now);
100
+ const candidates = await findEscrowHoldsDueForReminder(now, limit);
101
+
102
+ let reminded = 0;
103
+ let failed = 0;
104
+
105
+ // Budget at most 30s of enclave calls beside the two 30s delivery queues.
106
+ // Attempts ordering ensures a repeatedly unavailable cancellation cannot starve others.
107
+ const cancelLimit = Math.min(
108
+ limit,
109
+ 3,
110
+ Math.max(1, Math.floor(30_000 / environment.ESCROW_ENCLAVE_REMOTE_TIMEOUT_MS))
111
+ );
112
+ for (const hold of await findEscrowHoldsPendingEnclaveCancellation(cancelLimit)) {
113
+ try {
114
+ const userKey = hold.enclaveEnvelope
115
+ ? undefined
116
+ : await findUserKeyByAuthProvider(hold.authProvider.type, hold.authProvider.id);
117
+ if (!(await cancelHoldInEnclave(hold, userKey, now))) failed += 1;
118
+ } catch (error) {
119
+ failed += 1;
120
+ logJobError(hold._id, error);
121
+ }
122
+ }
123
+ // Check the whole backlog, not only this run's bounded retry batch.
124
+ if (await countEscrowCancellationsNearRelease(now)) logEscrowCancellationPending(true);
125
+
126
+ // A hold is inserted with this marker before the route attempts delivery,
127
+ // so Lambda freezes/timeouts cannot lose the security-critical start email.
128
+ for (const hold of await findEscrowHoldsPendingStartNotification(limit)) {
129
+ try {
130
+ await recordEscrowStartNotificationAttempt(hold._id, now);
131
+ const userKey = await findUserKeyByAuthProvider(
132
+ hold.authProvider.type,
133
+ hold.authProvider.id
134
+ );
135
+ if (
136
+ userKey &&
137
+ !(await notify({
138
+ kind: 'started',
139
+ hold,
140
+ userKey,
141
+ tenant: resolveTenantById(hold.tenantId),
142
+ }))
143
+ )
144
+ failed += 1;
145
+ } catch (error) {
146
+ failed += 1;
147
+ logJobError(hold._id, error);
148
+ }
149
+ }
150
+
151
+ for (const candidate of candidates) {
152
+ let claimed: Awaited<ReturnType<typeof claimEscrowHoldForReminder>> = null;
153
+ try {
154
+ // Lease before send; only successful delivery writes the sent marker.
155
+ claimed = await claimEscrowHoldForReminder(candidate._id, now);
156
+ if (!claimed) continue;
157
+
158
+ const userKey = await findUserKeyByAuthProvider(
159
+ claimed.authProvider.type,
160
+ claimed.authProvider.id
161
+ );
162
+ if (!userKey) continue;
163
+
164
+ const tenant = resolveTenantById(claimed.tenantId);
165
+
166
+ if (await notify({ kind: 'reminder', hold: claimed, userKey, tenant })) reminded += 1;
167
+ else failed += 1;
168
+ } catch (error) {
169
+ failed += 1;
170
+ logJobError(candidate._id, error);
171
+ } finally {
172
+ if (claimed) await releaseEscrowReminderClaim(claimed);
173
+ }
174
+ }
175
+
176
+ return { reminded, expired, failed };
177
+ };
@@ -0,0 +1,147 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Collection } from 'mongodb';
3
+ import mongodb from '@mongo';
4
+
5
+ export const AUTH_SUBJECTS_COLLECTION = 'authsubjects';
6
+
7
+ export interface AuthSubjectAttributes {
8
+ email?: string;
9
+ emailVerified: boolean;
10
+ displayName?: string;
11
+ pictureUrl?: string;
12
+ }
13
+
14
+ export interface MongoAuthSubjectType extends AuthSubjectAttributes {
15
+ subject: string;
16
+ identityKey: string;
17
+ createdAt: Date;
18
+ lastLoginAt: Date;
19
+ }
20
+
21
+ export const getAuthSubjectsCollection = (): Collection<MongoAuthSubjectType> =>
22
+ mongodb.collection<MongoAuthSubjectType>(AUTH_SUBJECTS_COLLECTION);
23
+
24
+ const MONGO_NAMESPACE_NOT_FOUND = 26;
25
+ const MONGO_INDEX_NOT_FOUND = 27;
26
+
27
+ const mongoErrorCode = (error: unknown): unknown =>
28
+ typeof error === 'object' && error !== null ? (error as { code?: unknown }).code : undefined;
29
+
30
+ /**
31
+ * `identityKey` is unique: each sign-in method maps to exactly one subject.
32
+ * `subject` is deliberately NOT unique — linked sign-in methods for the same
33
+ * person (email code + native Google/Apple with the same verified email) share
34
+ * one subject, so Keycloak and the wallet see a single account. Databases
35
+ * created before linking existed carry a unique `subject` index; drop it.
36
+ */
37
+ export const createAuthSubjectIndexes = async (): Promise<void> => {
38
+ const collection = getAuthSubjectsCollection();
39
+
40
+ const existing = await collection.indexes().catch(error => {
41
+ if (mongoErrorCode(error) === MONGO_NAMESPACE_NOT_FOUND) return [];
42
+ throw error;
43
+ });
44
+ const legacyUniqueSubject = existing.find(
45
+ index =>
46
+ index.unique === true && index.key?.subject === 1 && Object.keys(index.key).length === 1
47
+ );
48
+ if (legacyUniqueSubject?.name) {
49
+ await collection.dropIndex(legacyUniqueSubject.name).catch(error => {
50
+ if (mongoErrorCode(error) !== MONGO_INDEX_NOT_FOUND) throw error;
51
+ });
52
+ }
53
+
54
+ await collection.createIndex({ subject: 1 });
55
+ await collection.createIndex({ identityKey: 1 }, { unique: true });
56
+ };
57
+
58
+ let authSubjectIndexesReady: Promise<void> | undefined;
59
+
60
+ /** Create the indexes once per process (startup in Docker, first login in Lambda). */
61
+ export const ensureAuthSubjectIndexes = (): Promise<void> => {
62
+ if (!authSubjectIndexesReady) {
63
+ authSubjectIndexesReady = createAuthSubjectIndexes().catch(error => {
64
+ authSubjectIndexesReady = undefined;
65
+ console.error('Unable to create AuthSubject indexes:', error);
66
+ throw error;
67
+ });
68
+ }
69
+ return authSubjectIndexesReady;
70
+ };
71
+
72
+ const MONGO_DUPLICATE_KEY = 11000;
73
+
74
+ const isDuplicateKeyError = (error: unknown): boolean =>
75
+ typeof error === 'object' &&
76
+ error !== null &&
77
+ (error as { code?: unknown }).code === MONGO_DUPLICATE_KEY;
78
+
79
+ /**
80
+ * Shared upsert for both `getOrCreateAuthSubject` and `getOrCreateAuthSubjectLinkedTo`.
81
+ *
82
+ * Two concurrent first logins for the same identity can both miss the lookup
83
+ * and race on the insert; the unique `identityKey` index rejects the loser
84
+ * with E11000. Retrying once then finds the winner's document and only runs
85
+ * the `$set` half of the upsert. `subjectForInsert` only takes effect when this
86
+ * `identityKey` doesn't exist yet (`$setOnInsert`) — an existing record always
87
+ * keeps its own `subject`, regardless of what's passed here.
88
+ */
89
+ const upsertAuthSubject = async (
90
+ identityKey: string,
91
+ subjectForInsert: string,
92
+ attrs: AuthSubjectAttributes
93
+ ): Promise<MongoAuthSubjectType> => {
94
+ await ensureAuthSubjectIndexes();
95
+ const now = new Date();
96
+ const fields: Partial<MongoAuthSubjectType> = {
97
+ lastLoginAt: now,
98
+ emailVerified: attrs.emailVerified,
99
+ };
100
+ if (attrs.email !== undefined) fields.email = attrs.email;
101
+ if (attrs.displayName !== undefined) fields.displayName = attrs.displayName;
102
+ if (attrs.pictureUrl !== undefined) fields.pictureUrl = attrs.pictureUrl;
103
+
104
+ const upsert = () =>
105
+ getAuthSubjectsCollection().findOneAndUpdate(
106
+ { identityKey },
107
+ {
108
+ $setOnInsert: { subject: subjectForInsert, identityKey, createdAt: now },
109
+ $set: fields,
110
+ },
111
+ { upsert: true, returnDocument: 'after' }
112
+ );
113
+
114
+ let record: MongoAuthSubjectType | null;
115
+ try {
116
+ record = await upsert();
117
+ } catch (error) {
118
+ if (!isDuplicateKeyError(error)) throw error;
119
+ record = await upsert();
120
+ }
121
+ if (!record) throw new Error('Unable to persist authentication subject');
122
+ return record;
123
+ };
124
+
125
+ /** Persist a random, permanent subject independently of Firebase and UserKey. */
126
+ export const getOrCreateAuthSubject = (
127
+ identityKey: string,
128
+ attrs: AuthSubjectAttributes
129
+ ): Promise<MongoAuthSubjectType> => upsertAuthSubject(identityKey, randomUUID(), attrs);
130
+
131
+ /**
132
+ * Same as `getOrCreateAuthSubject`, but a brand-new `identityKey` adopts the given
133
+ * `subject` instead of minting a random one — used to unify a native social sign-in
134
+ * onto an already-existing email-code identity for the same verified email. If
135
+ * `identityKey` already has its own record (e.g. this provider/sub was seen before),
136
+ * that record's existing `subject` is kept untouched; it is never overwritten.
137
+ */
138
+ export const getOrCreateAuthSubjectLinkedTo = (
139
+ identityKey: string,
140
+ subject: string,
141
+ attrs: AuthSubjectAttributes
142
+ ): Promise<MongoAuthSubjectType> => upsertAuthSubject(identityKey, subject, attrs);
143
+
144
+ /** Read-only lookup used to find an existing identity to link a new one to. */
145
+ export const findAuthSubjectByIdentityKey = (
146
+ identityKey: string
147
+ ): Promise<MongoAuthSubjectType | null> => getAuthSubjectsCollection().findOne({ identityKey });