@learncard/lca-api-service 1.3.2 → 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.
- package/.env.example +99 -2
- package/AGENTS.md +155 -44
- package/CHANGELOG.md +84 -0
- package/bootstrapLambda.test.ts +76 -0
- package/compose.env +5 -0
- 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/accesslayer/signing-authority/index.d.ts +8 -2
- package/dist/app.d.ts +361 -8
- package/dist/cache/challenges.d.ts +2 -0
- 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/cache/recoverySessions.d.ts +42 -0
- package/dist/config/applyDockerStageConfig.d.ts +1 -0
- package/dist/config/cacheEnvironment.d.ts +16 -0
- package/dist/config/environment.d.ts +253 -2
- package/dist/config/environment.test.d.ts +1 -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/learnCard.signingAuthority.test.d.ts +1 -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/rateLimit.helpers.d.ts +4 -0
- package/dist/helpers/seedEncryption.helpers.d.ts +23 -0
- package/dist/helpers/seedEncryption.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/migrations/signingAuthoritySeeds.d.ts +13 -0
- package/dist/migrations/signingAuthoritySeedsCli.test.d.ts +1 -0
- package/dist/models/AuthSubject.d.ts +20 -0
- package/dist/models/EscrowHold.d.ts +153 -0
- package/dist/models/Notification.d.ts +5 -0
- package/dist/models/SigningAuthority.d.ts +7 -1
- package/dist/models/UserKey.d.ts +326 -3
- package/dist/models/UserKey.escrowPin.test.d.ts +1 -0
- package/dist/models/index.d.ts +2 -0
- package/dist/oidc.d.ts +5 -0
- package/dist/routes/auth.d.ts +31 -0
- package/dist/routes/escrow.d.ts +164 -0
- package/dist/routes/index.d.ts +6 -0
- package/dist/routes/keys.d.ts +182 -7
- package/dist/routes/notifications.d.ts +5 -0
- package/dist/routes/qr-login.d.ts +1 -1
- 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 +25 -0
- package/dist/services/escrow-enclave/notifications.d.ts +24 -0
- 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 +27 -0
- package/dist/services/escrow-enclave/softwareEnclave.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +111 -0
- package/dist/types/notifications.d.ts +5 -0
- package/dist/types/seed-encryption.d.ts +31 -0
- package/dist/types/seed-migration.d.ts +49 -0
- package/esbuildPlugins.cjs +1 -0
- package/lambda.ts +27 -112
- package/lambdaApp.ts +146 -0
- package/oidcLambda.test.ts +38 -0
- package/oidcLambda.ts +18 -0
- package/oidcLambdaApp.ts +11 -0
- package/package.json +30 -14
- package/scripts/README.md +88 -0
- package/scripts/check-seed-encryption-infrastructure.mjs +139 -0
- package/scripts/check-signing-authority-redis.mjs +93 -0
- 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/migrate-signing-authority-seeds-local.sh +51 -0
- package/scripts/migrate-signing-authority-seeds.mjs +105 -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 +40 -0
- package/seedMigrationLambda.ts +19 -0
- package/serverless-local.yml +28 -1
- package/serverless.function-env.cjs +58 -0
- package/serverless.yml +294 -26
- package/src/accesslayer/notifications/index.ts +9 -2
- package/src/accesslayer/signing-authority/create.ts +11 -7
- package/src/accesslayer/signing-authority/index.ts +4 -1
- package/src/app.ts +4 -0
- package/src/cache/challenges.ts +12 -2
- package/src/cache/getDel.test.ts +34 -0
- package/src/cache/getDel.ts +32 -0
- package/src/cache/in-memory-lru.ts +1 -0
- package/src/cache/index.ts +1 -1
- package/src/cache/login-tickets.ts +26 -0
- package/src/cache/recoverySessions.ts +252 -0
- package/src/config/applyDockerStageConfig.ts +6 -0
- package/src/config/cacheEnvironment.ts +34 -0
- package/src/config/environment.test.ts +159 -0
- package/src/config/environment.ts +164 -0
- 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 +13 -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/learnCard.helpers.test.ts +11 -1
- package/src/helpers/learnCard.helpers.ts +57 -45
- package/src/helpers/learnCard.signingAuthority.test.ts +194 -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/rateLimit.helpers.ts +112 -0
- package/src/helpers/seedEncryption.helpers.test.ts +427 -0
- package/src/helpers/seedEncryption.helpers.ts +372 -0
- package/src/helpers/signingAuthority.helpers.ts +4 -1
- 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/migrations/signingAuthoritySeeds.ts +430 -0
- package/src/migrations/signingAuthoritySeedsCli.test.ts +70 -0
- package/src/models/AuthSubject.ts +147 -0
- package/src/models/EscrowHold.ts +541 -0
- package/src/models/SigningAuthority.ts +14 -3
- package/src/models/UserKey.escrowPin.test.ts +49 -0
- package/src/models/UserKey.ts +1109 -40
- package/src/models/index.ts +2 -0
- package/src/models/pruneOrphanedRecoveryMethods.ts +3 -1
- package/src/oidc.ts +371 -0
- package/src/routes/auth.ts +173 -0
- package/src/routes/credentials.ts +8 -0
- package/src/routes/escrow.ts +1073 -0
- package/src/routes/firebase.ts +90 -48
- package/src/routes/index.ts +98 -5
- package/src/routes/keys.ts +1022 -174
- package/src/routes/qr-login.ts +51 -53
- package/src/routes/signing-authority.ts +39 -1
- package/src/services/delivery/adapters/postmark.adapter.ts +48 -1
- package/src/services/escrow-enclave/README.md +122 -0
- 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 +174 -0
- package/src/services/escrow-enclave/notifications.test.ts +377 -0
- package/src/services/escrow-enclave/notifications.ts +348 -0
- 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 +450 -0
- package/src/services/escrow-enclave/softwareEnclave.ts +254 -0
- package/src/services/escrow-enclave/types.ts +122 -0
- package/src/types/seed-encryption.ts +51 -0
- package/src/types/seed-migration.ts +61 -0
- package/test/auth-tickets.spec.ts +480 -0
- package/test/credentials.spec.ts +2 -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 +2328 -0
- package/test/keys-identity-rebind.spec.ts +456 -0
- package/test/keys-lifecycle-invariants.spec.ts +785 -0
- package/test/keys-recovery-confirmation.spec.ts +586 -0
- package/test/keys-recovery-rate-limit.spec.ts +123 -0
- package/test/keys-secret-leakage.spec.ts +265 -0
- package/test/models/authSubjectIndexes.spec.ts +132 -0
- package/test/models/escrow.spec.ts +572 -0
- 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-http.spec.ts +161 -0
- package/test/signing-authority-seed-migration.spec.ts +414 -0
- package/test/signing-authority.spec.ts +90 -0
- package/tsconfig.build.json +1 -1
- package/tsconfig.json +1 -0
- package/vitest.config.ts +24 -1
- package/vitest.integration.config.ts +25 -3
- package/dist/cache/signing-authority.d.ts +0 -6
- package/src/cache/signing-authority.ts +0 -37
package/src/routes/firebase.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
FirebaseCustomAuthResponseSchema,
|
|
15
15
|
} from '@helpers/firebase.helpers';
|
|
16
16
|
import cache from '@cache';
|
|
17
|
+
import { checkRateLimit, clearRateLimit, decrementRateLimit } from '@helpers/rateLimit.helpers';
|
|
17
18
|
import { TRPCError } from '@trpc/server';
|
|
18
19
|
import { getDeliveryService, getFrom } from '../services/delivery';
|
|
19
20
|
import { resolveLocaleByEmail } from '../helpers/locale.helpers';
|
|
@@ -67,6 +68,11 @@ const CODE_TTL_SECONDS = 5 * 60; // 5 minutes
|
|
|
67
68
|
const MAX_ATTEMPTS = 6;
|
|
68
69
|
const ATTEMPT_TTL_SECONDS = 5 * 60; // 5 minutes
|
|
69
70
|
|
|
71
|
+
// Rate limiting for login code verification (brute-force protection)
|
|
72
|
+
const LOGIN_VERIFY_MAX_ATTEMPTS_PER_EMAIL = 5; // max failed attempts per email
|
|
73
|
+
const LOGIN_VERIFY_MAX_ATTEMPTS_PER_IP = 30; // max attempts per IP per window
|
|
74
|
+
const LOGIN_VERIFY_IP_WINDOW_SECONDS = 600; // 10 minutes
|
|
75
|
+
|
|
70
76
|
export const CONTACT_METHOD_SESSION_PREFIX = 'contact_method_session:';
|
|
71
77
|
|
|
72
78
|
async function createFirebaseToken(keycloakToken: string): Promise<string> {
|
|
@@ -88,10 +94,7 @@ async function createFirebaseToken(keycloakToken: string): Promise<string> {
|
|
|
88
94
|
firebaseUser = existingUsers?.users?.[0];
|
|
89
95
|
|
|
90
96
|
if (firebaseUser?.uid !== uid) {
|
|
91
|
-
|
|
92
|
-
`⚠️ Existing account found for ${email}, linking Keycloak UID: ${uid}`
|
|
93
|
-
);
|
|
94
|
-
|
|
97
|
+
// Link existing account to Keycloak UID
|
|
95
98
|
await admin.auth().updateUser(firebaseUser.uid, {
|
|
96
99
|
emailVerified,
|
|
97
100
|
displayName,
|
|
@@ -103,8 +106,6 @@ async function createFirebaseToken(keycloakToken: string): Promise<string> {
|
|
|
103
106
|
}
|
|
104
107
|
|
|
105
108
|
if (!firebaseUser) {
|
|
106
|
-
console.log(`🆕 Creating new Firebase user for ${email} (UID: ${uid})`);
|
|
107
|
-
|
|
108
109
|
firebaseUser = await admin.auth().createUser({
|
|
109
110
|
uid,
|
|
110
111
|
email,
|
|
@@ -113,7 +114,7 @@ async function createFirebaseToken(keycloakToken: string): Promise<string> {
|
|
|
113
114
|
});
|
|
114
115
|
}
|
|
115
116
|
} catch (error) {
|
|
116
|
-
console.error('
|
|
117
|
+
console.error('[createFirebaseToken] Firebase user creation or lookup failed:', error);
|
|
117
118
|
throw new Error('Firebase user creation or lookup failed');
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -167,7 +168,7 @@ export const firebaseRouter = t.router({
|
|
|
167
168
|
|
|
168
169
|
return { userRecord: formattedUserRecord };
|
|
169
170
|
} catch (error) {
|
|
170
|
-
console.error('
|
|
171
|
+
console.error('[getFirebaseUserByEmail] Failed to fetch user:', error);
|
|
171
172
|
return { userRecord: null };
|
|
172
173
|
}
|
|
173
174
|
}),
|
|
@@ -190,7 +191,7 @@ export const firebaseRouter = t.router({
|
|
|
190
191
|
const firebaseToken = await createFirebaseToken(keycloakToken);
|
|
191
192
|
return { firebaseToken };
|
|
192
193
|
} catch (error) {
|
|
193
|
-
console.error('
|
|
194
|
+
console.error('[authenticateWithKeycloak] Authentication failed:', error);
|
|
194
195
|
throw new Error('Authentication failed');
|
|
195
196
|
}
|
|
196
197
|
}),
|
|
@@ -232,7 +233,7 @@ export const firebaseRouter = t.router({
|
|
|
232
233
|
|
|
233
234
|
return response.data ?? null;
|
|
234
235
|
} catch (error) {
|
|
235
|
-
console.
|
|
236
|
+
console.error('[authenticateWithScoutsSSO] Authentication failed:', error);
|
|
236
237
|
throw new TRPCError({
|
|
237
238
|
code: 'INTERNAL_SERVER_ERROR',
|
|
238
239
|
message: 'Unable to authenticate with Scouts SSO',
|
|
@@ -255,31 +256,32 @@ export const firebaseRouter = t.router({
|
|
|
255
256
|
const { email, locale } = input;
|
|
256
257
|
|
|
257
258
|
try {
|
|
258
|
-
// rate limit
|
|
259
|
-
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
259
|
+
// Atomic rate limit for sending codes (max 3 codes per 10 mins)
|
|
260
|
+
// Uses new key to avoid collision with legacy un-prefixed keys during rolling deploy
|
|
261
|
+
const sendAllowed = await checkRateLimit(
|
|
262
|
+
`login-send:${email}`,
|
|
263
|
+
MAX_ATTEMPTS,
|
|
264
|
+
ATTEMPT_TTL_SECONDS
|
|
265
|
+
);
|
|
266
|
+
if (!sendAllowed) {
|
|
263
267
|
return {
|
|
264
268
|
success: false,
|
|
265
269
|
error: 'Too many login attempts. Try again in a few minutes.',
|
|
266
270
|
};
|
|
267
271
|
}
|
|
268
272
|
|
|
269
|
-
await cache.set(attemptKey, attemptCount + 1, ATTEMPT_TTL_SECONDS);
|
|
270
|
-
|
|
271
|
-
// clear existing codes for this email (enforce 1 active code at a time)
|
|
272
|
-
const existingCodeKeys = await cache.keys(`login-code:${email}:*`);
|
|
273
|
-
if (existingCodeKeys?.length) {
|
|
274
|
-
await cache.delete(existingCodeKeys);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
273
|
// generate secure random 6-digit code
|
|
278
274
|
const code = crypto.randomInt(100000, 999999).toString();
|
|
279
|
-
const redisKey = `login-code:${email}:${code}`;
|
|
280
275
|
|
|
281
|
-
//
|
|
282
|
-
|
|
276
|
+
// Store code in Redis with 5-min TTL
|
|
277
|
+
// Key structure: login-code:${email} → code value
|
|
278
|
+
// This replaces any existing code (enforces 1 active code at a time)
|
|
279
|
+
const loginCodeKey = `login-code:${email}`;
|
|
280
|
+
await cache.set(loginCodeKey, code, CODE_TTL_SECONDS);
|
|
281
|
+
|
|
282
|
+
// Clear the per-email verification attempt counter so user can try the new code
|
|
283
|
+
// Note: IP counter is NOT cleared here to prevent abuse via email rotation
|
|
284
|
+
await clearRateLimit(`login-verify-attempts:${email}`);
|
|
283
285
|
|
|
284
286
|
// Login is pre-auth, so the client only knows its UI language.
|
|
285
287
|
// Prefer the account's saved locale (resolved by email via
|
|
@@ -306,14 +308,14 @@ export const firebaseRouter = t.router({
|
|
|
306
308
|
}),
|
|
307
309
|
});
|
|
308
310
|
} catch (error) {
|
|
309
|
-
console.error('Failed to send
|
|
311
|
+
console.error('[sendLoginVerificationCode] Failed to send email:', error);
|
|
310
312
|
return { success: false, error: 'Error sending login verification code' };
|
|
311
313
|
}
|
|
312
314
|
|
|
313
315
|
return { success: true };
|
|
314
|
-
} catch (err
|
|
315
|
-
console.error('
|
|
316
|
-
return { success: false, error:
|
|
316
|
+
} catch (err) {
|
|
317
|
+
console.error('[sendLoginVerificationCode] Unexpected error:', err);
|
|
318
|
+
return { success: false, error: 'An error occurred. Please try again.' };
|
|
317
319
|
}
|
|
318
320
|
}),
|
|
319
321
|
verifyLoginCode: openRoute
|
|
@@ -335,23 +337,63 @@ export const firebaseRouter = t.router({
|
|
|
335
337
|
token: z.string().optional(),
|
|
336
338
|
})
|
|
337
339
|
)
|
|
338
|
-
.mutation(async ({ input }) => {
|
|
340
|
+
.mutation(async ({ input, ctx }) => {
|
|
339
341
|
const { email, code } = input;
|
|
342
|
+
const clientIp = ctx.clientIp ?? 'unknown';
|
|
340
343
|
|
|
341
|
-
|
|
344
|
+
// Rate limit keys
|
|
345
|
+
const emailAttemptKey = `login-verify-attempts:${email}`;
|
|
346
|
+
const ipAttemptKey = `login-verify-ip:${clientIp}`;
|
|
342
347
|
|
|
343
348
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
349
|
+
const loginCodeKey = `login-code:${email}`;
|
|
350
|
+
|
|
351
|
+
// Atomic increment + check for IP-based rate limit
|
|
352
|
+
// This prevents TOCTOU races where concurrent requests could bypass the limit
|
|
353
|
+
const ipAllowed = await checkRateLimit(
|
|
354
|
+
ipAttemptKey,
|
|
355
|
+
LOGIN_VERIFY_MAX_ATTEMPTS_PER_IP,
|
|
356
|
+
LOGIN_VERIFY_IP_WINDOW_SECONDS
|
|
357
|
+
);
|
|
358
|
+
if (!ipAllowed) {
|
|
359
|
+
return {
|
|
360
|
+
success: false,
|
|
361
|
+
error: 'Too many attempts. Please resend code.',
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Atomic increment + check for per-email rate limit
|
|
366
|
+
const emailAllowed = await checkRateLimit(
|
|
367
|
+
emailAttemptKey,
|
|
368
|
+
LOGIN_VERIFY_MAX_ATTEMPTS_PER_EMAIL,
|
|
369
|
+
CODE_TTL_SECONDS
|
|
370
|
+
);
|
|
371
|
+
if (!emailAllowed) {
|
|
372
|
+
// Limit exceeded — invalidate the code
|
|
373
|
+
await cache.delete([loginCodeKey]);
|
|
374
|
+
return {
|
|
375
|
+
success: false,
|
|
376
|
+
error: 'Too many attempts. Please resend code.',
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Check if code exists and matches
|
|
381
|
+
const storedCode = await cache.get(loginCodeKey);
|
|
382
|
+
|
|
383
|
+
if (!storedCode || storedCode !== code) {
|
|
384
|
+
// Invalid code — counters already incremented above
|
|
347
385
|
return { success: false, error: 'Invalid or expired code.' };
|
|
348
386
|
}
|
|
349
387
|
|
|
350
|
-
// delete
|
|
351
|
-
await cache.delete([
|
|
388
|
+
// Valid code — delete it to prevent reuse
|
|
389
|
+
await cache.delete([loginCodeKey]);
|
|
390
|
+
|
|
391
|
+
// Clear email counter on success; decrement (not clear) IP counter
|
|
392
|
+
// so legitimate successes don't accumulate but we don't hand attackers a wipe
|
|
393
|
+
await clearRateLimit(emailAttemptKey);
|
|
394
|
+
await decrementRateLimit(ipAttemptKey);
|
|
352
395
|
|
|
353
|
-
//
|
|
354
|
-
console.log('Getting or creating Firebase user...', email);
|
|
396
|
+
// Get or create the Firebase user
|
|
355
397
|
let user;
|
|
356
398
|
try {
|
|
357
399
|
user = await app?.auth().getUserByEmail(email);
|
|
@@ -363,13 +405,13 @@ export const firebaseRouter = t.router({
|
|
|
363
405
|
return { success: false, error: 'Unable to find or create user.' };
|
|
364
406
|
}
|
|
365
407
|
|
|
366
|
-
//
|
|
408
|
+
// Create Firebase custom auth token
|
|
367
409
|
const token = await app?.auth().createCustomToken(user.uid);
|
|
368
410
|
|
|
369
411
|
return { success: true, token };
|
|
370
412
|
} catch (err: any) {
|
|
371
|
-
console.error('
|
|
372
|
-
return { success: false, error:
|
|
413
|
+
console.error('[verifyLoginCode] Unexpected error:', err);
|
|
414
|
+
return { success: false, error: 'An error occurred. Please try again.' };
|
|
373
415
|
}
|
|
374
416
|
}),
|
|
375
417
|
verifyNetworkHandoffToken: openRoute
|
|
@@ -475,9 +517,9 @@ export const firebaseRouter = t.router({
|
|
|
475
517
|
const token = await app?.auth().createCustomToken(user.uid);
|
|
476
518
|
|
|
477
519
|
return { success: true, token };
|
|
478
|
-
} catch (err
|
|
479
|
-
console.error('
|
|
480
|
-
return { success: false, error:
|
|
520
|
+
} catch (err) {
|
|
521
|
+
console.error('[verifyNetworkHandoffToken] Unexpected error:', err);
|
|
522
|
+
return { success: false, error: 'An error occurred. Please try again.' };
|
|
481
523
|
}
|
|
482
524
|
}),
|
|
483
525
|
getProofOfLoginVp: openRoute
|
|
@@ -521,9 +563,9 @@ export const firebaseRouter = t.router({
|
|
|
521
563
|
if (typeof result !== 'string') throw new Error('Error getting DID-Auth-JWT!');
|
|
522
564
|
|
|
523
565
|
return { success: true, vp: result };
|
|
524
|
-
} catch (err
|
|
525
|
-
console.error('
|
|
526
|
-
return { success: false, error:
|
|
566
|
+
} catch (err) {
|
|
567
|
+
console.error('[getProofOfLoginVp] Unexpected error:', err);
|
|
568
|
+
return { success: false, error: 'An error occurred. Please try again.' };
|
|
527
569
|
}
|
|
528
570
|
}),
|
|
529
571
|
});
|
package/src/routes/index.ts
CHANGED
|
@@ -12,7 +12,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
|
12
12
|
import { resolveTenantFromRequest, type ResolvedTenant } from '@learncard/email-templates';
|
|
13
13
|
|
|
14
14
|
import { getEmptyLearnCard } from '@helpers/learnCard.helpers';
|
|
15
|
-
import {
|
|
15
|
+
import { consumeChallengeForDid } from '@cache/challenges';
|
|
16
|
+
import { isEscrowEnabled } from '../services/escrow-enclave';
|
|
16
17
|
|
|
17
18
|
export type DidAuthVP = {
|
|
18
19
|
iss: string;
|
|
@@ -34,6 +35,13 @@ export type Context = {
|
|
|
34
35
|
tenant: ResolvedTenant;
|
|
35
36
|
debug?: boolean;
|
|
36
37
|
clientIp?: string;
|
|
38
|
+
// Value of the X-Auth-Token header — a caller's own provider token
|
|
39
|
+
// (e.g. a Firebase ID token), distinct from the Authorization header
|
|
40
|
+
// (reserved for the DID-Auth VP). Lets GET routes that verify a
|
|
41
|
+
// provider token (e.g. keys.getRecoveryShare, P0-4) read it without
|
|
42
|
+
// putting it in a query string, without colliding with clients that
|
|
43
|
+
// send a DID-Auth VP as Authorization for the same route.
|
|
44
|
+
providerToken?: string;
|
|
37
45
|
};
|
|
38
46
|
|
|
39
47
|
export const t = initTRPC.context<Context>().meta<OpenApiMeta>().create();
|
|
@@ -49,6 +57,13 @@ export const createContext = async (
|
|
|
49
57
|
const domainName = 'requestContext' in event ? event.requestContext.domainName : '';
|
|
50
58
|
const debug = environment.NODE_ENV === 'test';
|
|
51
59
|
|
|
60
|
+
// See Context.providerToken — a separate header from Authorization,
|
|
61
|
+
// which is reserved for the DID-Auth VP handled below.
|
|
62
|
+
const providerTokenHeader = (event.headers as Record<string, string | undefined>)[
|
|
63
|
+
'x-auth-token'
|
|
64
|
+
];
|
|
65
|
+
const providerToken = providerTokenHeader || undefined;
|
|
66
|
+
|
|
52
67
|
const domain =
|
|
53
68
|
!domainName || environment.IS_OFFLINE
|
|
54
69
|
? `localhost%3A${environment.PORT || 3000}`
|
|
@@ -116,10 +131,10 @@ export const createContext = async (
|
|
|
116
131
|
tenant,
|
|
117
132
|
debug,
|
|
118
133
|
clientIp,
|
|
134
|
+
providerToken,
|
|
119
135
|
};
|
|
120
136
|
|
|
121
|
-
const cacheResponse = await
|
|
122
|
-
await invalidateChallengeForDid(did, challenge);
|
|
137
|
+
const cacheResponse = await consumeChallengeForDid(did, challenge);
|
|
123
138
|
|
|
124
139
|
return {
|
|
125
140
|
user: { did, isChallengeValid: Boolean(cacheResponse), authorizedDid },
|
|
@@ -127,6 +142,7 @@ export const createContext = async (
|
|
|
127
142
|
tenant,
|
|
128
143
|
debug,
|
|
129
144
|
clientIp,
|
|
145
|
+
providerToken,
|
|
130
146
|
};
|
|
131
147
|
}
|
|
132
148
|
}
|
|
@@ -135,15 +151,92 @@ export const createContext = async (
|
|
|
135
151
|
console.error(e);
|
|
136
152
|
}
|
|
137
153
|
|
|
138
|
-
return { domain, tenant, clientIp };
|
|
154
|
+
return { domain, tenant, clientIp, providerToken };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// P0-4: Secret leakage — telemetry
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
//
|
|
161
|
+
// /keys/* inputs carry Firebase auth tokens, encrypted/plaintext key shares,
|
|
162
|
+
// and recovery artifacts. `attachRpcInput` is disabled below (globally, since
|
|
163
|
+
// this middleware chain is shared by every router — see app.ts) so Sentry
|
|
164
|
+
// never receives raw tRPC input. The event processor further down is
|
|
165
|
+
// defense-in-depth: even if input attachment is ever re-enabled upstream, or
|
|
166
|
+
// another integration attaches a raw payload to an event for a /keys/*
|
|
167
|
+
// request, this strips anything secret-shaped before it can leave the
|
|
168
|
+
// process.
|
|
169
|
+
const SECRET_FIELD_RE =
|
|
170
|
+
/share|token|seed|recoverykey|blob|envelope|sealed|ephemeral|resumetoken|pinproof|pinverifier/i;
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Recursively replaces values whose key matches SECRET_FIELD_RE with
|
|
174
|
+
* '[Redacted]'. Guards against circular references and caps recursion depth.
|
|
175
|
+
* Exported for unit testing.
|
|
176
|
+
*/
|
|
177
|
+
export const redactSecretFields = (
|
|
178
|
+
value: unknown,
|
|
179
|
+
depth = 0,
|
|
180
|
+
seen: Set<object> = new Set()
|
|
181
|
+
): unknown => {
|
|
182
|
+
if (depth > 10 || value === null || value === undefined) return value;
|
|
183
|
+
|
|
184
|
+
if (Array.isArray(value)) {
|
|
185
|
+
if (seen.has(value)) return '[Circular]';
|
|
186
|
+
seen.add(value);
|
|
187
|
+
return value.map(item => redactSecretFields(item, depth + 1, seen));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (typeof value === 'object') {
|
|
191
|
+
if (seen.has(value)) return '[Circular]';
|
|
192
|
+
seen.add(value);
|
|
193
|
+
return Object.fromEntries(
|
|
194
|
+
Object.entries(value as Record<string, unknown>).map(([key, val]) => [
|
|
195
|
+
key,
|
|
196
|
+
SECRET_FIELD_RE.test(key) ? '[Redacted]' : redactSecretFields(val, depth + 1, seen),
|
|
197
|
+
])
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return value;
|
|
139
202
|
};
|
|
140
203
|
|
|
204
|
+
const sentryTrpcMiddleware = Sentry.Handlers.trpcMiddleware({ attachRpcInput: false });
|
|
205
|
+
|
|
141
206
|
export const openRoute = t.procedure
|
|
142
|
-
.use(
|
|
207
|
+
.use(
|
|
208
|
+
t.middleware(({ path, type, next, getRawInput }) =>
|
|
209
|
+
sentryTrpcMiddleware({ path, type, next, rawInput: getRawInput })
|
|
210
|
+
)
|
|
211
|
+
)
|
|
143
212
|
.use(({ ctx, next, path }) => {
|
|
144
213
|
Sentry.configureScope(scope => {
|
|
145
214
|
scope.setTransactionName(`trpc-${path}`);
|
|
215
|
+
|
|
216
|
+
if (path.startsWith('keys.') || path.startsWith('escrow.')) {
|
|
217
|
+
scope.addEventProcessor(event => {
|
|
218
|
+
if (event.contexts) {
|
|
219
|
+
event.contexts = redactSecretFields(
|
|
220
|
+
event.contexts
|
|
221
|
+
) as typeof event.contexts;
|
|
222
|
+
}
|
|
223
|
+
if (event.extra) {
|
|
224
|
+
event.extra = redactSecretFields(event.extra) as typeof event.extra;
|
|
225
|
+
}
|
|
226
|
+
if (event.request?.data) {
|
|
227
|
+
event.request.data = redactSecretFields(event.request.data);
|
|
228
|
+
}
|
|
229
|
+
return event;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
146
232
|
});
|
|
233
|
+
// Gate escrow before input parsing and DID authorization, including when disabled.
|
|
234
|
+
if (path.startsWith('escrow.') && !isEscrowEnabled()) {
|
|
235
|
+
throw new TRPCError({
|
|
236
|
+
code: 'PRECONDITION_FAILED',
|
|
237
|
+
message: 'Escrow recovery is not available.',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
147
240
|
return next({ ctx });
|
|
148
241
|
});
|
|
149
242
|
|