@learncard/lca-api-service 1.4.0 → 1.5.1

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 (191) hide show
  1. package/.env.example +73 -0
  2. package/AGENTS.md +106 -10
  3. package/CHANGELOG.md +52 -0
  4. package/bootstrapLambda.test.ts +76 -0
  5. package/bundle.mjs +9 -1
  6. package/config/config.dev.json +23 -0
  7. package/config/config.example.json +22 -0
  8. package/config/config.json +1 -0
  9. package/config/config.production.json +12 -0
  10. package/config/config.scouts.dev.json +11 -0
  11. package/config/config.scouts.production.json +11 -0
  12. package/dist/app.d.ts +67 -3
  13. package/dist/cache/getDel.d.ts +2 -0
  14. package/dist/cache/getDel.test.d.ts +1 -0
  15. package/dist/cache/login-tickets.d.ts +12 -0
  16. package/dist/config/applyDockerStageConfig.d.ts +1 -0
  17. package/dist/config/cacheEnvironment.d.ts +16 -0
  18. package/dist/config/environment.d.ts +101 -0
  19. package/dist/config/escrowBundleMode.test.d.ts +1 -0
  20. package/dist/config/escrowDeployment.test.d.ts +1 -0
  21. package/dist/config/oidcEnvironment.d.ts +74 -0
  22. package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
  23. package/dist/config/stageConfig.d.ts +62 -0
  24. package/dist/config/stageConfig.test.d.ts +1 -0
  25. package/dist/didkit_wasm_bg.wasm +0 -0
  26. package/dist/docker-entry.d.ts +1 -1
  27. package/dist/helpers/auth.helpers.d.ts +1 -1
  28. package/dist/helpers/escrowCancelLink.d.ts +7 -0
  29. package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
  30. package/dist/helpers/escrowCancelToken.d.ts +4 -0
  31. package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
  32. package/dist/helpers/keycloak.helpers.d.ts +23 -0
  33. package/dist/helpers/login-ticket.helpers.d.ts +31 -0
  34. package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
  35. package/dist/helpers/oidc.helpers.d.ts +47 -0
  36. package/dist/helpers/rate-limit.helpers.d.ts +6 -0
  37. package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
  38. package/dist/helpers/social-token.helpers.d.ts +19 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +2 -2
  41. package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
  42. package/dist/jobs/escrowHoldReminders.d.ts +12 -0
  43. package/dist/models/AuthSubject.d.ts +20 -0
  44. package/dist/models/EscrowHold.d.ts +79 -1
  45. package/dist/models/Notification.d.ts +5 -0
  46. package/dist/models/UserKey.d.ts +11 -1
  47. package/dist/models/index.d.ts +1 -0
  48. package/dist/oidc.d.ts +5 -0
  49. package/dist/publish-metafile.json +1 -0
  50. package/dist/routes/auth.d.ts +31 -0
  51. package/dist/routes/escrow.d.ts +18 -4
  52. package/dist/routes/keys.d.ts +1 -0
  53. package/dist/routes/notifications.d.ts +5 -0
  54. package/dist/routes/signing-authority.d.ts +14 -0
  55. package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
  56. package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
  57. package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
  58. package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
  59. package/dist/services/escrow-enclave/index.d.ts +16 -0
  60. package/dist/services/escrow-enclave/notifications.d.ts +19 -4
  61. package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
  62. package/dist/services/escrow-enclave/production.test.d.ts +1 -0
  63. package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
  64. package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
  65. package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
  66. package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
  67. package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
  68. package/dist/services/escrow-enclave/types.d.ts +52 -8
  69. package/dist/types/notifications.d.ts +5 -0
  70. package/esbuildPlugins.cjs +1 -0
  71. package/lambda.ts +27 -117
  72. package/lambdaApp.ts +146 -0
  73. package/oidcLambda.test.ts +38 -0
  74. package/oidcLambda.ts +18 -0
  75. package/oidcLambdaApp.ts +11 -0
  76. package/package.json +22 -13
  77. package/project.json +8 -1
  78. package/scripts/README.md +88 -0
  79. package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
  80. package/scripts/escrow-blob-mode-report.test.ts +227 -0
  81. package/scripts/escrow-blob-mode-report.ts +465 -0
  82. package/scripts/keycloak-admin.ts +108 -0
  83. package/scripts/keycloak-provider-links.ts +147 -0
  84. package/scripts/print-seed-rollout-flags.ts +21 -0
  85. package/scripts/provision-keycloak-users.ts +419 -0
  86. package/seedMigrationApp.test.ts +145 -0
  87. package/seedMigrationApp.ts +50 -0
  88. package/seedMigrationLambda.test.ts +30 -135
  89. package/seedMigrationLambda.ts +15 -46
  90. package/serverless-local.yml +25 -1
  91. package/serverless.function-env.cjs +58 -0
  92. package/serverless.yml +174 -37
  93. package/src/accesslayer/notifications/index.ts +9 -2
  94. package/src/accesslayer/signing-authority/index.ts +4 -1
  95. package/src/app.ts +2 -0
  96. package/src/cache/getDel.test.ts +34 -0
  97. package/src/cache/getDel.ts +32 -0
  98. package/src/cache/index.ts +1 -1
  99. package/src/cache/login-tickets.ts +26 -0
  100. package/src/config/applyDockerStageConfig.ts +6 -0
  101. package/src/config/cacheEnvironment.ts +34 -0
  102. package/src/config/environment.test.ts +45 -2
  103. package/src/config/environment.ts +90 -23
  104. package/src/config/escrowBundleMode.test.ts +52 -0
  105. package/src/config/escrowDeployment.test.ts +114 -0
  106. package/src/config/oidcEnvironment.ts +59 -0
  107. package/src/config/serverlessFunctionEnv.test.ts +98 -0
  108. package/src/config/stageConfig.test.ts +26 -0
  109. package/src/config/stageConfig.ts +24 -0
  110. package/src/docker-entry.ts +9 -0
  111. package/src/helpers/auth.helpers.ts +68 -30
  112. package/src/helpers/escrowCancelLink.test.ts +60 -0
  113. package/src/helpers/escrowCancelLink.ts +38 -0
  114. package/src/helpers/escrowCancelToken.test.ts +105 -0
  115. package/src/helpers/escrowCancelToken.ts +47 -0
  116. package/src/helpers/keycloak.helpers.ts +120 -0
  117. package/src/helpers/login-ticket.helpers.test.ts +45 -0
  118. package/src/helpers/login-ticket.helpers.ts +91 -0
  119. package/src/helpers/oidc.helpers.ts +354 -0
  120. package/src/helpers/rate-limit.helpers.test.ts +61 -0
  121. package/src/helpers/rate-limit.helpers.ts +73 -0
  122. package/src/helpers/social-token.helpers.ts +144 -0
  123. package/src/index.ts +1 -0
  124. package/src/jobs/escrowBlobRewrap.ts +121 -0
  125. package/src/jobs/escrowHoldReminders.ts +177 -0
  126. package/src/models/AuthSubject.ts +147 -0
  127. package/src/models/EscrowHold.ts +321 -5
  128. package/src/models/UserKey.ts +91 -6
  129. package/src/models/index.ts +1 -0
  130. package/src/oidc.ts +371 -0
  131. package/src/routes/auth.ts +173 -0
  132. package/src/routes/escrow.ts +281 -34
  133. package/src/routes/keys.ts +5 -0
  134. package/src/routes/signing-authority.ts +39 -1
  135. package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
  136. package/src/services/escrow-enclave/README.md +81 -10
  137. package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
  138. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
  139. package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
  140. package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
  141. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
  142. package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
  143. package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
  144. package/src/services/escrow-enclave/blobIdentity.ts +55 -0
  145. package/src/services/escrow-enclave/cancellation.ts +59 -0
  146. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
  147. package/src/services/escrow-enclave/index.ts +145 -7
  148. package/src/services/escrow-enclave/notifications.test.ts +377 -0
  149. package/src/services/escrow-enclave/notifications.ts +340 -6
  150. package/src/services/escrow-enclave/production.test.ts +34 -0
  151. package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
  152. package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
  153. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
  154. package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
  155. package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
  156. package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
  157. package/src/services/escrow-enclave/types.ts +63 -17
  158. package/test/auth-tickets.spec.ts +480 -0
  159. package/test/helpers/keycloak-broker.ts +181 -0
  160. package/test/helpers/live-mongo.ts +9 -0
  161. package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
  162. package/test/jobs/escrowHoldReminders.spec.ts +319 -0
  163. package/test/keycloak-admin.spec.ts +58 -0
  164. package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
  165. package/test/keycloak-migration.integration.spec.ts +266 -0
  166. package/test/keycloak-provider-links.spec.ts +201 -0
  167. package/test/keycloak-social-broker.integration.spec.ts +228 -0
  168. package/test/keycloak-verify.integration.spec.ts +100 -0
  169. package/test/keycloak-verify.spec.ts +307 -0
  170. package/test/keys-escrow.spec.ts +963 -24
  171. package/test/models/authSubjectIndexes.spec.ts +132 -0
  172. package/test/models/escrow.spec.ts +375 -13
  173. package/test/notifications.spec.ts +4 -0
  174. package/test/oidc-e2e/broker.spec.ts +116 -0
  175. package/test/oidc-e2e/compose.yaml +33 -0
  176. package/test/oidc-e2e/fixtures.ts +245 -0
  177. package/test/oidc-e2e/lock.test.mjs +73 -0
  178. package/test/oidc-e2e/playwright.config.ts +21 -0
  179. package/test/oidc-e2e/run.mjs +312 -0
  180. package/test/oidc-e2e/tsconfig.json +10 -0
  181. package/test/oidc-e2e/types.ts +21 -0
  182. package/test/oidc.integration.spec.ts +54 -0
  183. package/test/oidc.spec.ts +697 -0
  184. package/test/oidcLambdaApp.boot.spec.ts +105 -0
  185. package/test/provision-keycloak-users.spec.ts +299 -0
  186. package/test/signing-authority.spec.ts +50 -0
  187. package/tsconfig.build.json +1 -1
  188. package/tsconfig.json +1 -0
  189. package/vitest.config.ts +24 -1
  190. package/vitest.integration.config.ts +20 -3
  191. package/serverless.escrow-env.cjs +0 -20
package/serverless.yml CHANGED
@@ -18,16 +18,14 @@ custom:
18
18
  number: 3
19
19
  config:
20
20
  CACHE_INSTANCE_SIZE: cache.t2.micro
21
- # Lambda caps each function's environment at 4KB. Escrow config is only read
22
- # by the trpc/api handlers, so it is attached per-function (not provider-wide)
23
- # and unset keys are omitted entirely.
24
- escrowEnvironment: ${file(./serverless.escrow-env.cjs)}
25
21
  esbuild:
26
22
  # serverless-esbuild does not support Bun as a Lambda artifact packager.
27
23
  # Bun remains the repo installer/runner; pnpm is used only for packaging externals.
28
24
  packager: pnpm
29
25
  plugins: esbuildPlugins.cjs
30
26
  bundle: true
27
+ # Deployment builds run once; avoid leaking rebuild contexts when plugins fail.
28
+ skipRebuild: true
31
29
  minify: true
32
30
  # @learncard/init's default (node.import) entry uses createRequire('./index.cjs'),
33
31
  # which esbuild cannot bundle — the emitted Lambda bundle is left with a runtime
@@ -133,37 +131,17 @@ provider:
133
131
  timeout: 29
134
132
  stage: ${opt:stage, "dev"}
135
133
  region: ${opt:region, "us-east-1"}
136
- environment:
137
- LAMBDA_STAGE: ${opt:stage, "dev"}
138
- SEED: ${env:SEED, ''}
139
- SA_SEED_KMS_KEY_ARN:
140
- Fn::GetAtt: [SigningAuthoritySeedKey, Arn]
141
- # First deployment upgrades readers only. Enable writes after every reader is compatible.
142
- SA_SEED_ENCRYPT_WRITES: ${param:saSeedEncryptWrites, env:SA_SEED_ENCRYPT_WRITES, 'false'}
143
- SA_SEED_ALLOW_LEGACY_READ: ${param:saSeedAllowLegacyRead, env:SA_SEED_ALLOW_LEGACY_READ, 'true'}
144
- DOMAIN_NAME: ${env:DOMAIN_NAME, ''}
145
- OPENAI_API_KEY: ${env:OPENAI_API_KEY, ''}
146
- METABASE_SECRET_KEY: ${env:METABASE_SECRET_KEY, ''}
147
- POSTMARK_SERVER_TOKEN: ${env:POSTMARK_SERVER_TOKEN, ''}
148
- POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS: ${env:POSTMARK_LOGIN_CODE_TEMPLATE_ALIAS, ''}
149
- POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS: ${env:POSTMARK_ENDORSEMENT_REQUEST_TEMPLATE_ALIAS, ''}
150
- POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS: ${env:POSTMARK_RECOVERY_EMAIL_CODE_TEMPLATE_ALIAS, ''}
151
- POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS: ${env:POSTMARK_RECOVERY_KEY_TEMPLATE_ALIAS, ''}
152
- POSTMARK_FROM_EMAIL: ${env:POSTMARK_FROM_EMAIL, ''}
153
- POSTMARK_BRAND_NAME: ${env:POSTMARK_BRAND_NAME, ''}
154
- PORT: ${opt:httpPort, "3000"}
155
- MONGO_URI: ${env:MONGO_URI, ''}
156
- MONGO_DB_NAME: ${env:MONGO_DB_NAME, ''}
157
- AUTHORIZED_DIDS: ${env:AUTHORIZED_DIDS, ''}
158
- GOOGLE_APPLICATION_CREDENTIAL: ${env:GOOGLE_APPLICATION_CREDENTIAL, ''}
159
- SCOUTS_SSO_CLIENT_SECRET: ${env:SCOUTS_SSO_CLIENT_SECRET, ''}
160
- LEARN_CLOUD_URL: ${env:LEARN_CLOUD_URL, ''}
161
- SENTRY_DSN: ${env:SENTRY_DSN, ''}
162
- SENTRY_ENV: ${env:SENTRY_ENV, ''}
163
- REDIS_HOST:
164
- 'Fn::GetAtt': [ElasticCacheCluster, RedisEndpoint.Address]
165
- REDIS_PORT:
166
- 'Fn::GetAtt': [ElasticCacheCluster, RedisEndpoint.Port]
134
+ # Bundle mode emits only infra; fallback mode retains the existing GitHub secrets.
135
+ environment: ${file(./serverless.function-env.cjs):provider}
136
+ # Only didWeb/swagger use this generated role. Explicit function roles do not inherit it.
137
+ # Bundle access must not grant cryptographic use of the hosted signing-authority KMS key.
138
+ iam:
139
+ role:
140
+ statements:
141
+ - Effect: Allow
142
+ Action: secretsmanager:GetSecretValue
143
+ Resource:
144
+ Fn::Sub: arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:lca-api/${sls:stage}/runtime-secrets-*
167
145
  tracing:
168
146
  apiGateway: true
169
147
  lambda: true
@@ -178,7 +156,7 @@ functions:
178
156
  handler: lambda.trpcHandler
179
157
  role: SigningAuthorityExecutionRole
180
158
  dependsOn: [SigningAuthoritySeedKeyPolicy]
181
- environment: ${self:custom.escrowEnvironment}
159
+ environment: ${file(./serverless.function-env.cjs):api}
182
160
  vpc:
183
161
  securityGroupIds:
184
162
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -191,7 +169,7 @@ functions:
191
169
  handler: lambda.openApiHandler
192
170
  role: SigningAuthorityExecutionRole
193
171
  dependsOn: [SigningAuthoritySeedKeyPolicy]
194
- environment: ${self:custom.escrowEnvironment}
172
+ environment: ${file(./serverless.function-env.cjs):api}
195
173
  vpc:
196
174
  securityGroupIds:
197
175
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -202,6 +180,7 @@ functions:
202
180
 
203
181
  didWeb:
204
182
  handler: lambda.didWebHandler
183
+ environment: ${file(./serverless.function-env.cjs):api}
205
184
  vpc:
206
185
  securityGroupIds:
207
186
  - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
@@ -213,12 +192,29 @@ functions:
213
192
  url:
214
193
  cors: true
215
194
 
195
+ oidc:
196
+ handler: oidcLambda.handler
197
+ # Only this function may read the private signing key (see OidcExecutionRole).
198
+ role: OidcExecutionRole
199
+ environment: ${file(./serverless.function-env.cjs):oidc}
200
+ vpc:
201
+ securityGroupIds:
202
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
203
+ subnetIds:
204
+ - Ref: PrivateSubnetA
205
+ events:
206
+ - httpApi: GET /.well-known/openid-configuration
207
+ - httpApi: ANY /oidc/{proxy+}
208
+
216
209
  seedMigration:
217
210
  handler: seedMigrationLambda.handler
218
211
  role: SigningAuthorityExecutionRole
219
212
  timeout: 900
220
213
  reservedConcurrency: 1
221
214
  dependsOn: [SigningAuthoritySeedKeyPolicy]
215
+ # Shares the SigningAuthority role, so it reads the runtime bundle when present and
216
+ # falls back to the same credentials as trpc/api otherwise (it needs SEED + Mongo).
217
+ environment: ${file(./serverless.function-env.cjs):api}
222
218
  # IAM invoke only: intentionally no events, schedule, or function URL.
223
219
  vpc:
224
220
  securityGroupIds:
@@ -228,6 +224,12 @@ functions:
228
224
 
229
225
  swagger:
230
226
  handler: lambda.swaggerUiHandler
227
+ environment: ${file(./serverless.function-env.cjs):api}
228
+ vpc:
229
+ securityGroupIds:
230
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
231
+ subnetIds:
232
+ - Ref: PrivateSubnetA
231
233
  package:
232
234
  patterns:
233
235
  - src/swagger-ui/**
@@ -235,7 +237,44 @@ functions:
235
237
  - httpApi: GET /docs
236
238
  - httpApi: GET /docs/{trpc+}
237
239
 
240
+ # Always deployed (no per-stage conditional: `schedule.enabled` needs a
241
+ # literal boolean, not an interpolated ESCROW_ENCLAVE_MODE env string);
242
+ # the handler itself no-ops when escrow is disabled for this stage/tenant.
243
+ escrowHoldReminders:
244
+ handler: lambda.escrowHoldRemindersHandler
245
+ timeout: 120
246
+ environment: ${file(./serverless.function-env.cjs):escrow}
247
+ vpc:
248
+ securityGroupIds:
249
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
250
+ subnetIds:
251
+ - Ref: PrivateSubnetA
252
+ events:
253
+ - schedule: rate(1 hour)
254
+
255
+ # Same always-deployed/no-op-when-disabled shape as escrowHoldReminders
256
+ # above; a bounded batch each run, so an hourly rate is generous relative
257
+ # to how quickly a key rotation's previous-key backlog should drain.
258
+ escrowBlobRewrap:
259
+ handler: lambda.escrowBlobRewrapHandler
260
+ timeout: 120
261
+ environment: ${file(./serverless.function-env.cjs):escrow}
262
+ vpc:
263
+ securityGroupIds:
264
+ - 'Fn::GetAtt': [ServerlessSecurityGroup, GroupId]
265
+ subnetIds:
266
+ - Ref: PrivateSubnetA
267
+ events:
268
+ - schedule: rate(1 hour)
269
+
238
270
  resources:
271
+ # Escrow enclave over PrivateLink: created only when the stage sets
272
+ # ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME (the enclave account's endpoint
273
+ # service). TLS trust for the hostname is pinned in enclaveTrustAnchors.ts.
274
+ Conditions:
275
+ EscrowEnclavePrivateLink:
276
+ Fn::Not:
277
+ - Fn::Equals: ['${env:ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME, ""}', '']
239
278
  Outputs:
240
279
  SigningAuthoritySeedKeyArn:
241
280
  Value:
@@ -259,6 +298,39 @@ resources:
259
298
  ManagedPolicyArns:
260
299
  - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
261
300
  - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AWSXRayDaemonWriteAccess
301
+ Policies:
302
+ - PolicyName: runtime-secrets
303
+ PolicyDocument:
304
+ Version: '2012-10-17'
305
+ Statement:
306
+ # seedMigration shares this role; bundle access is acceptable for now.
307
+ - Effect: Allow
308
+ Action: secretsmanager:GetSecretValue
309
+ Resource:
310
+ Fn::Sub: arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:lca-api/${sls:stage}/runtime-secrets-*
311
+ OidcExecutionRole:
312
+ Type: AWS::IAM::Role
313
+ Properties:
314
+ AssumeRolePolicyDocument:
315
+ Version: '2012-10-17'
316
+ Statement:
317
+ - Effect: Allow
318
+ Principal:
319
+ Service: lambda.amazonaws.com
320
+ Action: sts:AssumeRole
321
+ ManagedPolicyArns:
322
+ - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole
323
+ - Fn::Sub: arn:${AWS::Partition}:iam::aws:policy/AWSXRayDaemonWriteAccess
324
+ Policies:
325
+ - PolicyName: oidc-signing-key
326
+ PolicyDocument:
327
+ Version: '2012-10-17'
328
+ Statement:
329
+ # SecretString is the private JWK JSON; only its id belongs in Lambda env.
330
+ - Effect: Allow
331
+ Action: secretsmanager:GetSecretValue
332
+ Resource:
333
+ Fn::Sub: arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:lca-api/${sls:stage}/oidc-signing-jwk-*
262
334
  SigningAuthoritySeedKey:
263
335
  Type: AWS::KMS::Key
264
336
  DeletionPolicy: Retain
@@ -346,6 +418,67 @@ resources:
346
418
  Type: AWS::EC2::VPC
347
419
  Properties:
348
420
  CidrBlock: '10.0.0.0/16'
421
+ # A private hosted zone needs DNS hostnames; left at the default elsewhere.
422
+ EnableDnsHostnames:
423
+ Fn::If: [EscrowEnclavePrivateLink, true, Ref: AWS::NoValue]
424
+ EscrowEnclaveEndpointSecurityGroup:
425
+ Type: AWS::EC2::SecurityGroup
426
+ Condition: EscrowEnclavePrivateLink
427
+ Properties:
428
+ GroupDescription: Escrow enclave PrivateLink endpoint, 8443 from lca-api only
429
+ VpcId:
430
+ Ref: VPC
431
+ SecurityGroupIngress:
432
+ - IpProtocol: tcp
433
+ FromPort: 8443
434
+ ToPort: 8443
435
+ SourceSecurityGroupId:
436
+ Ref: ServerlessSecurityGroup
437
+ EscrowEnclaveEndpoint:
438
+ Type: AWS::EC2::VPCEndpoint
439
+ Condition: EscrowEnclavePrivateLink
440
+ Properties:
441
+ VpcEndpointType: Interface
442
+ ServiceName: ${env:ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME, ''}
443
+ VpcId:
444
+ Ref: VPC
445
+ SubnetIds:
446
+ - Ref: PrivateSubnetA
447
+ SecurityGroupIds:
448
+ - Ref: EscrowEnclaveEndpointSecurityGroup
449
+ PrivateDnsEnabled: false
450
+ EscrowEnclaveHostedZone:
451
+ Type: AWS::Route53::HostedZone
452
+ Condition: EscrowEnclavePrivateLink
453
+ Properties:
454
+ Name: ${env:ESCROW_ENCLAVE_HOSTNAME, ''}
455
+ VPCs:
456
+ - VPCId:
457
+ Ref: VPC
458
+ VPCRegion: ${self:provider.region}
459
+ EscrowEnclaveDnsRecord:
460
+ Type: AWS::Route53::RecordSet
461
+ Condition: EscrowEnclavePrivateLink
462
+ Properties:
463
+ HostedZoneId:
464
+ Ref: EscrowEnclaveHostedZone
465
+ Name: ${env:ESCROW_ENCLAVE_HOSTNAME, ''}
466
+ Type: A
467
+ # DnsEntries[0] is "<hosted zone id>:<regional endpoint DNS name>".
468
+ AliasTarget:
469
+ HostedZoneId:
470
+ Fn::Select:
471
+ - 0
472
+ - Fn::Split:
473
+ - ':'
474
+ - Fn::Select: [0, Fn::GetAtt: [EscrowEnclaveEndpoint, DnsEntries]]
475
+ DNSName:
476
+ Fn::Select:
477
+ - 1
478
+ - Fn::Split:
479
+ - ':'
480
+ - Fn::Select: [0, Fn::GetAtt: [EscrowEnclaveEndpoint, DnsEntries]]
481
+ EvaluateTargetHealth: false
349
482
  IP:
350
483
  Type: AWS::EC2::EIP
351
484
  Properties:
@@ -456,6 +589,10 @@ resources:
456
589
  Properties:
457
590
  AutoMinorVersionUpgrade: true
458
591
  Engine: redis
592
+ # GETDEL (login codes/tickets/auth codes) needs Redis >= 6.2. The
593
+ # version is deliberately NOT pinned here: existing clusters are
594
+ # already on 7.x, and a pin below the running version is rejected
595
+ # by CloudFormation as a downgrade (failing the whole deploy).
459
596
  CacheNodeType: ${self:custom.config.CACHE_INSTANCE_SIZE}
460
597
  NumCacheNodes: 1
461
598
  VpcSecurityGroupIds:
@@ -12,12 +12,19 @@ export const getNotificationsCollection = () => {
12
12
 
13
13
  export const Notifications = getNotificationsCollection();
14
14
 
15
- Notifications.createIndex({ 'to.did': 1, read: 1, sent: -1, _id: 1 });
15
+ // Fire-and-forget at module load: every Lambda imports this, so an uncaught
16
+ // rejection (e.g. a Mongo connect that outlives a frozen invocation) would
17
+ // crash whichever function thaws next.
18
+ const logIndexFailure = (error: unknown): void => {
19
+ console.error('Notifications index creation failed:', error);
20
+ };
21
+
22
+ Notifications.createIndex({ 'to.did': 1, read: 1, sent: -1, _id: 1 }).catch(logIndexFailure);
16
23
  Notifications.createIndex({
17
24
  'to.did': 1,
18
25
  type: 1,
19
26
  'data.metadata.connectionPrompt.promptId': 1,
20
- });
27
+ }).catch(logIndexFailure);
21
28
 
22
29
  const LEGACY_CREDENTIAL_REFRESH_INDEX = 'to.did_1_type_1_data.metadata.deliveryKey_1';
23
30
  const MANAGED_CREDENTIAL_REFRESH_INDEX = 'managed_credential_refresh_delivery_key_unique';
@@ -7,4 +7,7 @@ export const getSigningAuthoritiesCollection = () => {
7
7
 
8
8
  export const SigningAuthorities = getSigningAuthoritiesCollection();
9
9
 
10
- SigningAuthorities.createIndex({ ownerDid: 1, name: 1 }, { unique: true });
10
+ // Fire-and-forget at module load; see accesslayer/notifications for why it must not reject.
11
+ SigningAuthorities.createIndex({ ownerDid: 1, name: 1 }, { unique: true }).catch(error => {
12
+ console.error('Signing authority unique index creation failed:', error);
13
+ });
package/src/app.ts CHANGED
@@ -12,6 +12,7 @@ import { preferencesRouter } from '@routes/preferences';
12
12
  import { keysRouter } from '@routes/keys';
13
13
  import { escrowRouter } from '@routes/escrow';
14
14
  import { qrLoginRouter } from '@routes/qr-login';
15
+ import { authRouter } from '@routes/auth';
15
16
  import { testRouter, type TestRouter } from '@routes/test';
16
17
  import { environment } from '@environment';
17
18
 
@@ -28,6 +29,7 @@ const routes = {
28
29
  keys: keysRouter,
29
30
  escrow: escrowRouter,
30
31
  qrLogin: qrLoginRouter,
32
+ auth: authRouter,
31
33
  };
32
34
 
33
35
  export const appRouter = t.router<typeof routes & { test?: TestRouter }>({
@@ -0,0 +1,34 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import cache from '@cache';
3
+ import { consumeMatchingCode } from './getDel';
4
+
5
+ vi.mock('@cache', async () => {
6
+ const { default: Redis } = await import('ioredis-mock');
7
+ return { default: { node: new Redis() } };
8
+ });
9
+
10
+ afterEach(async () => {
11
+ await cache.node.flushall();
12
+ });
13
+
14
+ describe('consumeMatchingCode', () => {
15
+ it('preserves a valid code after a wrong guess', async () => {
16
+ await cache.node.set('login-code:test@example.com', '123456');
17
+ expect(await consumeMatchingCode('login-code:test@example.com', '000000')).toBe(false);
18
+ expect(await cache.node.get('login-code:test@example.com')).toBe('123456');
19
+ });
20
+
21
+ it('allows exactly one concurrent redemption and rejects replay', async () => {
22
+ await cache.node.set('login-code:test@example.com', '123456');
23
+ const results = await Promise.all([
24
+ consumeMatchingCode('login-code:test@example.com', '123456'),
25
+ consumeMatchingCode('login-code:test@example.com', '123456'),
26
+ ]);
27
+ expect(results.filter(Boolean)).toHaveLength(1);
28
+ expect(await consumeMatchingCode('login-code:test@example.com', '123456')).toBe(false);
29
+ });
30
+
31
+ it('rejects missing codes', async () => {
32
+ expect(await consumeMatchingCode('login-code:missing@example.com', '123456')).toBe(false);
33
+ });
34
+ });
@@ -0,0 +1,32 @@
1
+ import cache from '@cache';
2
+
3
+ /**
4
+ * Atomically read-and-delete a key so its value can only ever be consumed once.
5
+ *
6
+ * Redis `GETDEL` is a single command, so concurrent redemptions return the value
7
+ * to exactly one caller — closing the read-then-delete race that `get` + `delete`
8
+ * would leave open. Used for login codes, login tickets and authorization codes.
9
+ *
10
+ * NOTE: `GETDEL` requires Redis >= 6.2. Deployed ElastiCache clusters are on 7.x
11
+ * (see the `ElasticCacheCluster` note in `serverless.yml`); `ioredis-mock`
12
+ * implements it for the no-Redis fallback.
13
+ */
14
+ export const getDel = async (key: string): Promise<string | null> => {
15
+ const redis = cache.redis ?? cache.node;
16
+ return redis.getdel(key);
17
+ };
18
+
19
+ /** Consume a code only when its value matches, atomically preventing replay. */
20
+ export const consumeMatchingCode = async (key: string, code: string): Promise<boolean> => {
21
+ const redis = cache.redis ?? cache.node;
22
+ const consumed = await redis.eval(
23
+ `if redis.call('GET', KEYS[1]) == ARGV[1] then
24
+ return redis.call('DEL', KEYS[1])
25
+ end
26
+ return 0`,
27
+ 1,
28
+ key,
29
+ code
30
+ );
31
+ return consumed === 1;
32
+ };
@@ -1,6 +1,6 @@
1
1
  /// <reference path="../global.d.ts" />
2
2
 
3
- import { environment } from '@environment';
3
+ import { environment } from '@config/cacheEnvironment';
4
4
  import Redis, { RedisValue, RedisKey } from 'ioredis';
5
5
  import MemoryRedis, { Redis as RedisMockType } from 'ioredis-mock';
6
6
 
@@ -0,0 +1,26 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import cache from '@cache';
3
+ import { getDel } from '@cache/getDel';
4
+
5
+ export interface LoginTicketPayload {
6
+ subject: string;
7
+ email?: string;
8
+ emailVerified?: boolean;
9
+ phoneNumber?: string;
10
+ phoneNumberVerified?: boolean;
11
+ name?: string;
12
+ picture?: string;
13
+ identityKey: string;
14
+ }
15
+
16
+ export const issueLoginTicket = async (payload: LoginTicketPayload): Promise<string> => {
17
+ const ticket = randomBytes(32).toString('base64url');
18
+ const result = await cache.set(`login-ticket:${ticket}`, JSON.stringify(payload), 60);
19
+ if (result !== 'OK') throw new Error('Failed to persist login ticket');
20
+ return ticket;
21
+ };
22
+
23
+ export const redeemLoginTicket = async (ticket: string): Promise<LoginTicketPayload | null> => {
24
+ const raw = await getDel(`login-ticket:${ticket}`);
25
+ return raw ? (JSON.parse(raw) as LoginTicketPayload) : null;
26
+ };
@@ -0,0 +1,6 @@
1
+ import { applyLcaApiStageConfig } from './stageConfig';
2
+
3
+ // Side-effect import: runs before @environment is evaluated so Docker/local processes
4
+ // pick up checked-in per-stage config. Keyed on CONFIG_STAGE (Lambda uses LAMBDA_STAGE);
5
+ // explicit non-empty env values from the developer's .env still win.
6
+ applyLcaApiStageConfig(process.env.CONFIG_STAGE);
@@ -0,0 +1,34 @@
1
+ import 'dotenv/config';
2
+
3
+ import { z } from 'zod';
4
+ import {
5
+ optionalEnvironmentPort,
6
+ optionalEnvironmentString,
7
+ parseEnvironment,
8
+ } from '@learncard/helpers';
9
+
10
+ /**
11
+ * Focused environment contract for the Redis-backed cache so `src/cache/index.ts`
12
+ * can boot without the full schema's required SEED/MONGO. The cache reads only
13
+ * REDIS_HOST/REDIS_PORT, mirroring the parsers used by the full schema.
14
+ */
15
+ export const cacheEnvironmentShape = {
16
+ REDIS_HOST: optionalEnvironmentString,
17
+ REDIS_PORT: optionalEnvironmentPort,
18
+ } satisfies z.ZodRawShape;
19
+
20
+ export const cacheEnvironmentSchema = z.object(cacheEnvironmentShape);
21
+
22
+ export type CacheEnvironment = z.output<typeof cacheEnvironmentSchema>;
23
+
24
+ export const parseCacheEnvironment = (
25
+ raw: Record<string, unknown>,
26
+ source = 'process environment'
27
+ ): CacheEnvironment =>
28
+ parseEnvironment(cacheEnvironmentSchema, raw, {
29
+ project: 'lca-api (cache)',
30
+ source,
31
+ examplePath: 'services/learn-card-network/lca-api/.env.example',
32
+ });
33
+
34
+ export const environment = parseCacheEnvironment(process.env);
@@ -1,5 +1,18 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { parseLcaApiEnvironment } from './environment';
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { isEscrowReleaseKillSwitchEnabled, parseLcaApiEnvironment } from './environment';
3
+
4
+ afterEach(() => vi.unstubAllEnvs());
5
+
6
+ describe('live escrow release kill switch', () => {
7
+ it.each(['1', 'true'])('blocks releases for %s', value => {
8
+ vi.stubEnv('ESCROW_RELEASE_KILL_SWITCH', value);
9
+ expect(isEscrowReleaseKillSwitchEnabled()).toBe(true);
10
+ });
11
+ it.each(['0', 'false', ''])('allows releases for %s', value => {
12
+ vi.stubEnv('ESCROW_RELEASE_KILL_SWITCH', value);
13
+ expect(isEscrowReleaseKillSwitchEnabled()).toBe(false);
14
+ });
15
+ });
3
16
 
4
17
  const production = {
5
18
  NODE_ENV: 'production',
@@ -114,3 +127,33 @@ describe('signing-authority seed environment', () => {
114
127
  ).not.toThrow();
115
128
  });
116
129
  });
130
+
131
+ describe('Keycloak startup configuration', () => {
132
+ it.each([undefined, '', ' ', ' , , '])('rejects missing audiences: %s', audiences => {
133
+ expect(() =>
134
+ parseLcaApiEnvironment({
135
+ NODE_ENV: 'test',
136
+ IS_OFFLINE: 'true',
137
+ KEYCLOAK_ISSUERS: 'https://identity.example/realms/learncard',
138
+ KEYCLOAK_AUDIENCES: audiences,
139
+ })
140
+ ).toThrow(/KEYCLOAK_AUDIENCES/);
141
+ });
142
+ it('allows disabled Keycloak and valid issuer/audience configuration', () => {
143
+ expect(() =>
144
+ parseLcaApiEnvironment({
145
+ NODE_ENV: 'test',
146
+ IS_OFFLINE: 'true',
147
+ KEYCLOAK_ISSUERS: ' , ',
148
+ })
149
+ ).not.toThrow();
150
+ expect(() =>
151
+ parseLcaApiEnvironment({
152
+ NODE_ENV: 'test',
153
+ IS_OFFLINE: 'true',
154
+ KEYCLOAK_ISSUERS: 'https://identity.example/realms/learncard',
155
+ KEYCLOAK_AUDIENCES: ' , learncard-app, ',
156
+ })
157
+ ).not.toThrow();
158
+ });
159
+ });