@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.
- package/.env.example +73 -0
- package/AGENTS.md +106 -10
- package/CHANGELOG.md +52 -0
- package/bootstrapLambda.test.ts +76 -0
- package/bundle.mjs +9 -1
- package/config/config.dev.json +23 -0
- package/config/config.example.json +22 -0
- package/config/config.json +1 -0
- package/config/config.production.json +12 -0
- package/config/config.scouts.dev.json +11 -0
- package/config/config.scouts.production.json +11 -0
- package/dist/app.d.ts +67 -3
- package/dist/cache/getDel.d.ts +2 -0
- package/dist/cache/getDel.test.d.ts +1 -0
- package/dist/cache/login-tickets.d.ts +12 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +101 -0
- package/dist/config/escrowBundleMode.test.d.ts +1 -0
- package/dist/config/escrowDeployment.test.d.ts +1 -0
- package/dist/config/oidcEnvironment.d.ts +74 -0
- package/dist/config/serverlessFunctionEnv.test.d.ts +1 -0
- package/dist/config/stageConfig.d.ts +62 -0
- package/dist/config/stageConfig.test.d.ts +1 -0
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/docker-entry.d.ts +1 -1
- package/dist/helpers/auth.helpers.d.ts +1 -1
- package/dist/helpers/escrowCancelLink.d.ts +7 -0
- package/dist/helpers/escrowCancelLink.test.d.ts +1 -0
- package/dist/helpers/escrowCancelToken.d.ts +4 -0
- package/dist/helpers/escrowCancelToken.test.d.ts +1 -0
- package/dist/helpers/keycloak.helpers.d.ts +23 -0
- package/dist/helpers/login-ticket.helpers.d.ts +31 -0
- package/dist/helpers/login-ticket.helpers.test.d.ts +1 -0
- package/dist/helpers/oidc.helpers.d.ts +47 -0
- package/dist/helpers/rate-limit.helpers.d.ts +6 -0
- package/dist/helpers/rate-limit.helpers.test.d.ts +1 -0
- package/dist/helpers/social-token.helpers.d.ts +19 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/jobs/escrowBlobRewrap.d.ts +9 -0
- package/dist/jobs/escrowHoldReminders.d.ts +12 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +79 -1
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/UserKey.d.ts +11 -1
- package/dist/models/index.d.ts +1 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/publish-metafile.json +1 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +18 -4
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/signing-authority.d.ts +14 -0
- package/dist/services/escrow-enclave/blobIdentity.d.ts +10 -0
- package/dist/services/escrow-enclave/blobIdentity.test.d.ts +1 -0
- package/dist/services/escrow-enclave/cancellation.d.ts +3 -0
- package/dist/services/escrow-enclave/enclaveTrustAnchors.d.ts +1 -0
- package/dist/services/escrow-enclave/index.d.ts +16 -0
- package/dist/services/escrow-enclave/notifications.d.ts +19 -4
- package/dist/services/escrow-enclave/notifications.test.d.ts +1 -0
- package/dist/services/escrow-enclave/production.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.contract.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.d.ts +20 -0
- package/dist/services/escrow-enclave/remoteEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/remoteEnclave.tls.test.d.ts +1 -0
- package/dist/services/escrow-enclave/softwareEnclave.d.ts +12 -2
- package/dist/services/escrow-enclave/types.d.ts +52 -8
- package/dist/types/notifications.d.ts +5 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -117
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +22 -13
- package/project.json +8 -1
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +18 -2
- package/scripts/escrow-blob-mode-report.test.ts +227 -0
- package/scripts/escrow-blob-mode-report.ts +465 -0
- package/scripts/keycloak-admin.ts +108 -0
- package/scripts/keycloak-provider-links.ts +147 -0
- package/scripts/print-seed-rollout-flags.ts +21 -0
- package/scripts/provision-keycloak-users.ts +419 -0
- package/seedMigrationApp.test.ts +145 -0
- package/seedMigrationApp.ts +50 -0
- package/seedMigrationLambda.test.ts +30 -135
- package/seedMigrationLambda.ts +15 -46
- package/serverless-local.yml +25 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +174 -37
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +2 -0
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +45 -2
- package/src/config/environment.ts +90 -23
- package/src/config/escrowBundleMode.test.ts +52 -0
- package/src/config/escrowDeployment.test.ts +114 -0
- package/src/config/oidcEnvironment.ts +59 -0
- package/src/config/serverlessFunctionEnv.test.ts +98 -0
- package/src/config/stageConfig.test.ts +26 -0
- package/src/config/stageConfig.ts +24 -0
- package/src/docker-entry.ts +9 -0
- package/src/helpers/auth.helpers.ts +68 -30
- package/src/helpers/escrowCancelLink.test.ts +60 -0
- package/src/helpers/escrowCancelLink.ts +38 -0
- package/src/helpers/escrowCancelToken.test.ts +105 -0
- package/src/helpers/escrowCancelToken.ts +47 -0
- package/src/helpers/keycloak.helpers.ts +120 -0
- package/src/helpers/login-ticket.helpers.test.ts +45 -0
- package/src/helpers/login-ticket.helpers.ts +91 -0
- package/src/helpers/oidc.helpers.ts +354 -0
- package/src/helpers/rate-limit.helpers.test.ts +61 -0
- package/src/helpers/rate-limit.helpers.ts +73 -0
- package/src/helpers/social-token.helpers.ts +144 -0
- package/src/index.ts +1 -0
- package/src/jobs/escrowBlobRewrap.ts +121 -0
- package/src/jobs/escrowHoldReminders.ts +177 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +321 -5
- package/src/models/UserKey.ts +91 -6
- package/src/models/index.ts +1 -0
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/escrow.ts +281 -34
- package/src/routes/keys.ts +5 -0
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +43 -1
- package/src/services/escrow-enclave/README.md +81 -10
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/pinned-leaf.pem +12 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-ca.pem +11 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.key +5 -0
- package/src/services/escrow-enclave/__fixtures__/tls/rogue-leaf.pem +12 -0
- package/src/services/escrow-enclave/blobIdentity.test.ts +42 -0
- package/src/services/escrow-enclave/blobIdentity.ts +55 -0
- package/src/services/escrow-enclave/cancellation.ts +59 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +34 -0
- package/src/services/escrow-enclave/index.ts +145 -7
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +340 -6
- package/src/services/escrow-enclave/production.test.ts +34 -0
- package/src/services/escrow-enclave/remoteEnclave.contract.test.ts +344 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +490 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +62 -0
- package/src/services/escrow-enclave/remoteEnclave.ts +263 -0
- package/src/services/escrow-enclave/softwareEnclave.test.ts +231 -30
- package/src/services/escrow-enclave/softwareEnclave.ts +97 -11
- package/src/services/escrow-enclave/types.ts +63 -17
- package/test/auth-tickets.spec.ts +480 -0
- package/test/helpers/keycloak-broker.ts +181 -0
- package/test/helpers/live-mongo.ts +9 -0
- package/test/jobs/escrowBlobRewrap.spec.ts +262 -0
- package/test/jobs/escrowHoldReminders.spec.ts +319 -0
- package/test/keycloak-admin.spec.ts +58 -0
- package/test/keycloak-broker-roundtrip.integration.spec.ts +40 -0
- package/test/keycloak-migration.integration.spec.ts +266 -0
- package/test/keycloak-provider-links.spec.ts +201 -0
- package/test/keycloak-social-broker.integration.spec.ts +228 -0
- package/test/keycloak-verify.integration.spec.ts +100 -0
- package/test/keycloak-verify.spec.ts +307 -0
- package/test/keys-escrow.spec.ts +963 -24
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +375 -13
- package/test/notifications.spec.ts +4 -0
- package/test/oidc-e2e/broker.spec.ts +116 -0
- package/test/oidc-e2e/compose.yaml +33 -0
- package/test/oidc-e2e/fixtures.ts +245 -0
- package/test/oidc-e2e/lock.test.mjs +73 -0
- package/test/oidc-e2e/playwright.config.ts +21 -0
- package/test/oidc-e2e/run.mjs +312 -0
- package/test/oidc-e2e/tsconfig.json +10 -0
- package/test/oidc-e2e/types.ts +21 -0
- package/test/oidc.integration.spec.ts +54 -0
- package/test/oidc.spec.ts +697 -0
- package/test/oidcLambdaApp.boot.spec.ts +105 -0
- package/test/provision-keycloak-users.spec.ts +299 -0
- package/test/signing-authority.spec.ts +50 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +20 -3
- 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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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: ${
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/cache/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/// <reference path="../global.d.ts" />
|
|
2
2
|
|
|
3
|
-
import { environment } from '@
|
|
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
|
+
});
|