From b0a400e136e5dd1f1a6428ae6061b99ce2245438 Mon Sep 17 00:00:00 2001 From: Kai Haase Date: Fri, 6 Feb 2026 15:34:26 +0100 Subject: [PATCH] 11.13.3: Optimize BetterAuth sign-in performance with native scrypt, migration fast-path, and bounded in-memory stores --- load-tests/README.md | 104 +++++++ load-tests/helpers.js | 196 +++++++++++++ load-tests/iam-graphql-jwt.k6.js | 129 +++++++++ load-tests/iam-memory-soak.k6.js | 146 ++++++++++ load-tests/iam-rest-cookie.k6.js | 219 +++++++++++++++ load-tests/iam-session.k6.js | 114 ++++++++ load-tests/iam-sign-in.k6.js | 104 +++++++ load-tests/monitor-memory.sh | 63 +++++ load-tests/run.sh | 151 ++++++++++ package-lock.json | 4 +- package.json | 8 +- spectaql.yml | 2 +- src/config.env.ts | 261 +++++++++++++++++- .../interfaces/server-options.interface.ts | 7 + .../modules/better-auth/better-auth.config.ts | 51 ++++ ...-better-auth-email-verification.service.ts | 16 ++ .../core-better-auth-rate-limiter.service.ts | 40 +++ .../core-better-auth-user.mapper.ts | 48 ++-- 18 files changed, 1630 insertions(+), 33 deletions(-) create mode 100644 load-tests/README.md create mode 100644 load-tests/helpers.js create mode 100644 load-tests/iam-graphql-jwt.k6.js create mode 100644 load-tests/iam-memory-soak.k6.js create mode 100644 load-tests/iam-rest-cookie.k6.js create mode 100644 load-tests/iam-session.k6.js create mode 100644 load-tests/iam-sign-in.k6.js create mode 100755 load-tests/monitor-memory.sh create mode 100755 load-tests/run.sh diff --git a/load-tests/README.md b/load-tests/README.md new file mode 100644 index 0000000..ffaf74c --- /dev/null +++ b/load-tests/README.md @@ -0,0 +1,104 @@ +# BetterAuth IAM Load Tests + +k6-based load tests for the BetterAuth IAM module of `@lenne.tech/nest-server`. + +## Prerequisites + +```bash +# Install k6 +brew install k6 + +# Build the server +npm run build + +# Ensure MongoDB is running on localhost:27017 +``` + +## Quick Start + +```bash +# Start server in one terminal +npm start + +# Run all load tests in another terminal +./load-tests/run.sh + +# Or start server automatically +./load-tests/run.sh --with-server +``` + +## Available Tests + +| Test | File | VUs | Duration | What it measures | +|------|------|-----|----------|------------------| +| **Sign-In** | `iam-sign-in.k6.js` | 50 | 80s | Sign-in endpoint latency | +| **GraphQL JWT** | `iam-graphql-jwt.k6.js` | 100 | 140s | JWT auth middleware overhead | +| **Session** | `iam-session.k6.js` | 50 | 80s | Session cookie / DB lookup | +| **Memory Soak** | `iam-memory-soak.k6.js` | 20 | 10 min | Memory leak detection | + +## Running Individual Tests + +```bash +# Run a single test directly +k6 run load-tests/iam-sign-in.k6.js + +# Via runner (partial name match) +./load-tests/run.sh sign-in +./load-tests/run.sh graphql +./load-tests/run.sh session +./load-tests/run.sh memory-soak + +# Custom base URL +BASE_URL=http://staging.example.com:3000 k6 run load-tests/iam-sign-in.k6.js +``` + +## Memory Soak Test + +The memory soak test is excluded from the default run because it takes 10 minutes. + +```bash +# Run soak test +./load-tests/run.sh memory-soak + +# Monitor memory in a separate terminal +./load-tests/monitor-memory.sh + +# Or with specific PID and interval +./load-tests/monitor-memory.sh 5 +``` + +The memory monitor outputs a CSV file in `load-tests/results/` for later analysis. + +## Results + +Test results are saved as JSON in `load-tests/results/`: + +``` +load-tests/results/ + iam-sign-in-20250206-143022.json + iam-graphql-jwt-20250206-143022.json + memory-20250206-143022.csv +``` + +## Thresholds + +Each test defines pass/fail thresholds: + +| Test | Metric | p95 Target | p99 Target | Success Rate | +|------|--------|------------|------------|-------------| +| Sign-In | `iam_sign_in_duration` | < 2000ms | < 5000ms | > 95% | +| GraphQL JWT | `gql_jwt_duration` | < 1000ms | < 3000ms | > 95% | +| Session | `iam_session_duration` | < 1500ms | < 4000ms | > 95% | + +## Suspected Bottlenecks + +These tests target the following suspected performance issues: + +| Test | Bottleneck | Code Location | +|------|-----------|---------------| +| Sign-In | DB queries per request | `core-better-auth.service.ts` | +| GraphQL JWT | JWKS key import per request | `core-better-auth-token.service.ts` | +| GraphQL JWT | HS256 key re-derivation | `core-better-auth-token.service.ts` | +| Session | MongoDB aggregation pipeline | `core-better-auth.service.ts` | +| Memory Soak | Unbounded rate limiter Map | `core-better-auth-rate-limiter.service.ts` | +| Memory Soak | Unbounded email verification Map | `core-better-auth-email-verification.service.ts` | diff --git a/load-tests/helpers.js b/load-tests/helpers.js new file mode 100644 index 0000000..909d784 --- /dev/null +++ b/load-tests/helpers.js @@ -0,0 +1,196 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +export const BASE_URL = __ENV.BASE_URL || 'http://127.0.0.1:3000'; +export const IAM_URL = `${BASE_URL}/iam`; +export const GQL_URL = `${BASE_URL}/graphql`; + +export const JSON_HEADERS = { + 'Content-Type': 'application/json', +}; + +// --------------------------------------------------------------------------- +// IAM helpers +// --------------------------------------------------------------------------- + +/** + * Sign up a new user via BetterAuth IAM. + * Returns the parsed JSON body (contains user + session). + */ +export function iamSignUp(email, password, name) { + const res = http.post( + `${IAM_URL}/sign-up/email`, + JSON.stringify({ email, password, name, termsAndPrivacyAccepted: true }), + { headers: JSON_HEADERS, tags: { endpoint: 'sign-up' } }, + ); + + check(res, { + 'sign-up status ok': (r) => r.status === 200 || r.status === 201, + }); + + return { body: safeJson(res), res }; +} + +/** + * Sign in an existing user via BetterAuth IAM. + * Returns the parsed JSON body (contains user + session + token). + */ +export function iamSignIn(email, password) { + const res = http.post( + `${IAM_URL}/sign-in/email`, + JSON.stringify({ email, password }), + { headers: JSON_HEADERS, tags: { endpoint: 'sign-in' } }, + ); + + check(res, { + 'sign-in status 200': (r) => r.status === 200, + }); + + return { body: safeJson(res), res }; +} + +/** + * Get current session via GET /iam/session. + * Accepts either a JWT bearer token or a session cookie string. + */ +export function iamGetSession({ token, cookie }) { + const headers = { ...JSON_HEADERS }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const params = { headers, tags: { endpoint: 'session' } }; + + if (cookie) { + // k6 jar-style cookies + params.cookies = { 'iam.session_token': cookie }; + } + + const res = http.get(`${IAM_URL}/session`, params); + + check(res, { + 'session status 200': (r) => r.status === 200, + }); + + return { body: safeJson(res), res }; +} + +// --------------------------------------------------------------------------- +// GraphQL helpers +// --------------------------------------------------------------------------- + +/** + * Execute an authenticated GraphQL query. + */ +export function graphqlQuery(query, variables, token) { + const headers = { ...JSON_HEADERS }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const res = http.post( + GQL_URL, + JSON.stringify({ query, variables }), + { headers, tags: { endpoint: 'graphql' } }, + ); + + check(res, { + 'graphql status 200': (r) => r.status === 200, + }); + + return { body: safeJson(res), res }; +} + +// --------------------------------------------------------------------------- +// Token extraction +// --------------------------------------------------------------------------- + +/** + * Extract JWT from sign-in response. + * BetterAuth returns the token in the session object or as a set-cookie header. + */ +export function extractJwt(signInResult) { + // 1. Try response body → session.token or token + const body = signInResult.body; + if (body) { + if (body.token) return body.token; + if (body.session?.token) return body.session.token; + } + + // 2. Try set-cookie header (lt-jwt-token) + const setCookie = signInResult.res.headers['Set-Cookie'] || signInResult.res.headers['set-cookie']; + if (setCookie) { + const cookies = Array.isArray(setCookie) ? setCookie : [setCookie]; + for (const c of cookies) { + // BetterAuth JWT cookie + const jwtMatch = c.match(/lt-jwt-token=([^;]+)/); + if (jwtMatch) return jwtMatch[1]; + // Generic bearer token cookie + const tokenMatch = c.match(/token=([^;]+)/); + if (tokenMatch) return tokenMatch[1]; + } + } + + return null; +} + +/** + * Extract session cookie value from sign-in response. + */ +export function extractSessionCookie(signInResult) { + const setCookie = signInResult.res.headers['Set-Cookie'] || signInResult.res.headers['set-cookie']; + if (!setCookie) return null; + + const cookies = Array.isArray(setCookie) ? setCookie : [setCookie]; + for (const c of cookies) { + const match = c.match(/iam\.session_token=([^;]+)/); + if (match) return match[1]; + } + return null; +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +function safeJson(res) { + try { + return res.json(); + } catch { + return null; + } +} + +/** + * Generate a unique test email address. + */ +export function uniqueEmail(prefix) { + const ts = Date.now(); + const rnd = Math.random().toString(36).substring(2, 8); + return `${prefix || 'k6'}-${ts}-${rnd}@loadtest.local`; +} + +/** + * Wait for server health-check to pass. + * k6 setup functions can call this once. + */ +export function waitForServer(maxRetries = 30, delayMs = 1000) { + for (let i = 0; i < maxRetries; i++) { + try { + const res = http.get(`${BASE_URL}/health`, { timeout: '5s' }); + if (res.status === 200) return true; + } catch { + // ignore + } + // k6 doesn't have a blocking sleep in setup – use a busy-wait + const end = Date.now() + delayMs; + while (Date.now() < end) { + // spin + } + } + return false; +} diff --git a/load-tests/iam-graphql-jwt.k6.js b/load-tests/iam-graphql-jwt.k6.js new file mode 100644 index 0000000..f5e3a46 --- /dev/null +++ b/load-tests/iam-graphql-jwt.k6.js @@ -0,0 +1,129 @@ +/** + * k6 Load Test: JWT-Authenticated GraphQL Performance + * + * Measures the middleware overhead for JWT verification on GraphQL requests. + * Each VU executes a simple authenticated query with a Bearer JWT token. + * + * This test specifically stresses: + * - CoreBetterAuthMiddleware JWT verification path + * - JWKS key import / HS256 key derivation + * - Session lookup after JWT decode + * + * Run: + * k6 run load-tests/iam-graphql-jwt.k6.js + */ +import { check, sleep } from 'k6'; +import { Trend, Counter, Rate } from 'k6/metrics'; +import { + iamSignUp, + iamSignIn, + extractJwt, + graphqlQuery, + uniqueEmail, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Custom metrics +// --------------------------------------------------------------------------- + +const gqlDuration = new Trend('gql_jwt_duration', true); +const gqlErrors = new Counter('gql_jwt_errors'); +const gqlSuccess = new Rate('gql_jwt_success'); + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export const options = { + scenarios: { + graphql_jwt_load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 100 }, // ramp up + { duration: '2m', target: 100 }, // sustained + { duration: '10s', target: 0 }, // ramp down + ], + gracefulRampDown: '5s', + }, + }, + thresholds: { + gql_jwt_duration: ['p(95)<1000', 'p(99)<3000'], + gql_jwt_success: ['rate>0.95'], + http_req_failed: ['rate<0.05'], + }, +}; + +// --------------------------------------------------------------------------- +// GraphQL query used by VUs +// --------------------------------------------------------------------------- + +const AUTH_QUERY = ` + query { + betterAuthEnabled + } +`; + +// --------------------------------------------------------------------------- +// Setup – create user and obtain JWT +// --------------------------------------------------------------------------- + +export function setup() { + const email = uniqueEmail('gqljwt'); + const password = 'K6GqlJwtPass123'; + const name = 'K6 GQL JWT User'; + + iamSignUp(email, password, name); + + const signIn = iamSignIn(email, password); + const jwt = extractJwt(signIn); + + if (!jwt) { + console.error('Setup: could not extract JWT from sign-in response'); + console.error(JSON.stringify(signIn.body)); + } + + return { jwt }; +} + +// --------------------------------------------------------------------------- +// VU code +// --------------------------------------------------------------------------- + +export default function (data) { + if (!data.jwt) { + console.error('No JWT available – skipping iteration'); + sleep(1); + return; + } + + const start = Date.now(); + + const result = graphqlQuery(AUTH_QUERY, {}, data.jwt); + const elapsed = Date.now() - start; + + gqlDuration.add(elapsed); + + const ok = check(result.res, { + 'status 200': (r) => r.status === 200, + 'no graphql errors': () => !result.body?.errors, + 'has data': () => !!result.body?.data, + }); + + if (ok) { + gqlSuccess.add(1); + } else { + gqlSuccess.add(0); + gqlErrors.add(1); + } + + sleep(0.1); // high frequency to stress middleware +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +export function teardown() { + console.log('GraphQL JWT load test completed.'); +} diff --git a/load-tests/iam-memory-soak.k6.js b/load-tests/iam-memory-soak.k6.js new file mode 100644 index 0000000..5b300fc --- /dev/null +++ b/load-tests/iam-memory-soak.k6.js @@ -0,0 +1,146 @@ +/** + * k6 Soak Test: Memory-Leak Detection + * + * Runs for 10 minutes with moderate load, exercising: + * - Sign-in with many unique users (new rate limiter entries per IP) + * - Unique X-Forwarded-For IPs to grow the rate limiter Map + * - Session lookups + * + * Monitor server memory externally while this test runs: + * watch -n 2 'ps -o rss,vsz,pid -p $(pgrep -f "node.*nest-server")' + * + * Or use the companion script: + * ./load-tests/monitor-memory.sh + * + * Run: + * k6 run load-tests/iam-memory-soak.k6.js + */ +import { check, sleep } from 'k6'; +import { Trend, Counter } from 'k6/metrics'; +import http from 'k6/http'; +import { + BASE_URL, + IAM_URL, + JSON_HEADERS, + iamSignUp, + uniqueEmail, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Custom metrics +// --------------------------------------------------------------------------- + +const soakDuration = new Trend('soak_request_duration', true); +const soakErrors = new Counter('soak_errors'); + +// --------------------------------------------------------------------------- +// Options – 20 VUs for 10 minutes +// --------------------------------------------------------------------------- + +export const options = { + scenarios: { + memory_soak: { + executor: 'constant-vus', + vus: 20, + duration: '10m', + }, + }, + thresholds: { + http_req_failed: ['rate<0.30'], // allow some 429s from rate limiter + }, +}; + +// --------------------------------------------------------------------------- +// Setup – pre-create a pool of users for sign-in +// --------------------------------------------------------------------------- + +const USER_POOL_SIZE = 50; + +export function setup() { + const users = []; + const password = 'K6SoakPass123'; + + for (let i = 0; i < USER_POOL_SIZE; i++) { + const email = uniqueEmail(`soak${i}`); + const result = iamSignUp(email, password, `Soak User ${i}`); + if (result.res.status === 200) { + users.push({ email, password }); + } + } + + console.log(`Setup: created ${users.length} soak users`); + return { users }; +} + +// --------------------------------------------------------------------------- +// VU code – mixed workload with unique IPs +// --------------------------------------------------------------------------- + +let iterCounter = 0; + +export default function (data) { + if (!data.users || data.users.length === 0) { + console.error('No soak users available'); + sleep(1); + return; + } + + iterCounter++; + + // Pick a random user from the pool + const user = data.users[Math.floor(Math.random() * data.users.length)]; + + // Generate a unique X-Forwarded-For IP to stress rate limiter Map growth + const fakeIp = `10.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}.${Math.floor(Math.random() * 256)}`; + + const headers = { + ...JSON_HEADERS, + 'X-Forwarded-For': fakeIp, + }; + + const start = Date.now(); + + // Alternate between sign-in and session check + if (iterCounter % 3 === 0) { + // Sign-in request + const res = http.post( + `${IAM_URL}/sign-in/email`, + JSON.stringify({ email: user.email, password: user.password }), + { headers, tags: { endpoint: 'soak-sign-in' } }, + ); + + const elapsed = Date.now() - start; + soakDuration.add(elapsed); + + const ok = check(res, { + 'sign-in ok or rate-limited': (r) => r.status === 200 || r.status === 429, + }); + + if (!ok) soakErrors.add(1); + } else { + // Session check (unauthenticated – should return 401) + const res = http.get(`${IAM_URL}/session`, { + headers: { ...headers, 'Authorization': `Bearer invalid-token-${iterCounter}` }, + tags: { endpoint: 'soak-session' }, + }); + + const elapsed = Date.now() - start; + soakDuration.add(elapsed); + + // 401 is expected for invalid tokens + check(res, { + 'session responds': (r) => r.status === 200 || r.status === 401, + }); + } + + sleep(0.5); +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +export function teardown() { + console.log('Memory soak test completed.'); + console.log('Check server memory with: ps -o rss,pid -p $(pgrep -f "node.*nest-server")'); +} diff --git a/load-tests/iam-rest-cookie.k6.js b/load-tests/iam-rest-cookie.k6.js new file mode 100644 index 0000000..cfd84d7 --- /dev/null +++ b/load-tests/iam-rest-cookie.k6.js @@ -0,0 +1,219 @@ +/** + * k6 Load Test: Full REST Flow with Cookie/JWT handling + * + * Tests the complete REST authentication lifecycle: + * 1. Sign-Up (creates user) + * 2. Sign-In (obtains token/cookie) + * 3. Authenticated GET /iam/session (with cookie or JWT) + * 4. Sign-Out + * + * This test exercises the full middleware chain including: + * - CoreBetterAuthMiddleware (token/cookie parsing) + * - CoreBetterAuthApiMiddleware (BetterAuth native handler forwarding) + * - Password hashing (scrypt via native crypto) + * - Session creation and lookup + * - User mapping and linking + * + * Run: + * k6 run load-tests/iam-rest-cookie.k6.js + */ +import { check, sleep } from 'k6'; +import { Trend, Counter, Rate } from 'k6/metrics'; +import http from 'k6/http'; +import { + BASE_URL, + IAM_URL, + JSON_HEADERS, + iamSignUp, + iamSignIn, + extractJwt, + extractSessionCookie, + uniqueEmail, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Custom metrics +// --------------------------------------------------------------------------- + +const signInDuration = new Trend('rest_sign_in_duration', true); +const sessionDuration = new Trend('rest_session_duration', true); +const signOutDuration = new Trend('rest_sign_out_duration', true); +const fullFlowDuration = new Trend('rest_full_flow_duration', true); +const restErrors = new Counter('rest_flow_errors'); +const restSuccess = new Rate('rest_flow_success'); + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export const options = { + scenarios: { + rest_cookie_flow: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 30 }, // ramp up + { duration: '1m', target: 30 }, // sustained + { duration: '10s', target: 0 }, // ramp down + ], + gracefulRampDown: '5s', + }, + }, + thresholds: { + rest_sign_in_duration: ['p(95)<1500'], + rest_session_duration: ['p(95)<500'], + rest_full_flow_duration: ['p(95)<2500'], + rest_flow_success: ['rate>0.95'], + http_req_failed: ['rate<0.05'], + }, +}; + +// --------------------------------------------------------------------------- +// Setup – create a pool of users for the VUs +// --------------------------------------------------------------------------- + +const USER_POOL_SIZE = 30; + +export function setup() { + const users = []; + const password = 'K6RestCookiePass123'; + + for (let i = 0; i < USER_POOL_SIZE; i++) { + const email = uniqueEmail(`rest${i}`); + const signUp = iamSignUp(email, password, `K6 REST User ${i}`); + if (signUp.res.status === 200 || signUp.res.status === 201) { + users.push({ email, password }); + } + } + + console.log(`Setup: created ${users.length} REST test users`); + return { users }; +} + +// --------------------------------------------------------------------------- +// VU code – full REST flow per iteration +// --------------------------------------------------------------------------- + +export default function (data) { + if (!data.users || data.users.length === 0) { + console.error('No users available'); + sleep(1); + return; + } + + const user = data.users[Math.floor(Math.random() * data.users.length)]; + const flowStart = Date.now(); + let allOk = true; + + // ---- Step 1: Sign-In ---- + const signInStart = Date.now(); + const signInRes = http.post( + `${IAM_URL}/sign-in/email`, + JSON.stringify({ email: user.email, password: user.password }), + { headers: JSON_HEADERS, tags: { endpoint: 'rest-sign-in' } }, + ); + signInDuration.add(Date.now() - signInStart); + + const signInOk = check(signInRes, { + 'sign-in 200': (r) => r.status === 200, + 'has token or cookie': (r) => { + const body = safeJson(r); + if (body?.token) return true; + const setCookie = r.headers['Set-Cookie'] || r.headers['set-cookie']; + return !!setCookie; + }, + }); + if (!signInOk) allOk = false; + + // Extract auth credentials + const signInBody = safeJson(signInRes); + const jwt = signInBody?.token || signInBody?.session?.token; + + // Also extract session cookie if present + let sessionCookie = null; + const setCookie = signInRes.headers['Set-Cookie'] || signInRes.headers['set-cookie']; + if (setCookie) { + const cookies = Array.isArray(setCookie) ? setCookie : [setCookie]; + for (const c of cookies) { + const match = c.match(/iam\.session_token=([^;]+)/); + if (match) { sessionCookie = match[1]; break; } + } + } + + // ---- Step 2: Authenticated session check ---- + if (jwt || sessionCookie) { + const sessionStart = Date.now(); + + const sessionHeaders = { ...JSON_HEADERS }; + const sessionParams = { headers: sessionHeaders, tags: { endpoint: 'rest-session' } }; + + if (sessionCookie) { + // Cookie-based auth + sessionParams.cookies = { 'iam.session_token': sessionCookie }; + } else if (jwt) { + // JWT-based auth + sessionHeaders['Authorization'] = `Bearer ${jwt}`; + } + + const sessionRes = http.get(`${IAM_URL}/session`, sessionParams); + sessionDuration.add(Date.now() - sessionStart); + + const sessionOk = check(sessionRes, { + 'session 200': (r) => r.status === 200, + 'session responds': () => { + const body = safeJson(sessionRes); + // In JWT mode: { success: false } (no cookie session) + // In cookie mode: { user: {...}, session: {...} } + return body !== null; + }, + }); + if (!sessionOk) allOk = false; + + // ---- Step 3: Sign-Out ---- + const signOutStart = Date.now(); + + const signOutHeaders = { ...JSON_HEADERS }; + const signOutParams = { headers: signOutHeaders, tags: { endpoint: 'rest-sign-out' } }; + + if (sessionCookie) { + signOutParams.cookies = { 'iam.session_token': sessionCookie }; + } else if (jwt) { + signOutHeaders['Authorization'] = `Bearer ${jwt}`; + } + + const signOutRes = http.post(`${IAM_URL}/sign-out`, null, signOutParams); + signOutDuration.add(Date.now() - signOutStart); + + check(signOutRes, { + 'sign-out ok': (r) => r.status === 200 || r.status === 201 || r.status === 204, + }); + } + + // Full flow timing + fullFlowDuration.add(Date.now() - flowStart); + + if (allOk) { + restSuccess.add(1); + } else { + restSuccess.add(0); + restErrors.add(1); + } + + sleep(0.3); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function safeJson(res) { + try { return res.json(); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +export function teardown() { + console.log('REST Cookie/JWT flow load test completed.'); +} diff --git a/load-tests/iam-session.k6.js b/load-tests/iam-session.k6.js new file mode 100644 index 0000000..df631ea --- /dev/null +++ b/load-tests/iam-session.k6.js @@ -0,0 +1,114 @@ +/** + * k6 Load Test: Session-Cookie Performance + * + * Measures GET /iam/session latency when using session cookies. + * This test stresses the MongoDB aggregation pipeline that looks up + * sessions and joins user data. + * + * Run: + * k6 run load-tests/iam-session.k6.js + */ +import { check, sleep } from 'k6'; +import { Trend, Counter, Rate } from 'k6/metrics'; +import { + iamSignUp, + iamSignIn, + extractSessionCookie, + extractJwt, + iamGetSession, + uniqueEmail, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Custom metrics +// --------------------------------------------------------------------------- + +const sessionDuration = new Trend('iam_session_duration', true); +const sessionErrors = new Counter('iam_session_errors'); +const sessionSuccess = new Rate('iam_session_success'); + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export const options = { + scenarios: { + session_load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 50 }, // ramp up + { duration: '1m', target: 50 }, // sustained + { duration: '10s', target: 0 }, // ramp down + ], + gracefulRampDown: '5s', + }, + }, + thresholds: { + iam_session_duration: ['p(95)<1500', 'p(99)<4000'], + iam_session_success: ['rate>0.95'], + http_req_failed: ['rate<0.05'], + }, +}; + +// --------------------------------------------------------------------------- +// Setup – create user, sign in, extract session cookie (or JWT fallback) +// --------------------------------------------------------------------------- + +export function setup() { + const email = uniqueEmail('session'); + const password = 'K6SessionPass123'; + const name = 'K6 Session User'; + + iamSignUp(email, password, name); + + const signIn = iamSignIn(email, password); + const cookie = extractSessionCookie(signIn); + const jwt = extractJwt(signIn); + + if (!cookie && !jwt) { + console.error('Setup: could not extract session cookie or JWT'); + console.error(JSON.stringify(signIn.body)); + } + + return { cookie, jwt }; +} + +// --------------------------------------------------------------------------- +// VU code +// --------------------------------------------------------------------------- + +export default function (data) { + const start = Date.now(); + + // Prefer cookie-based session; fall back to JWT bearer + const result = data.cookie + ? iamGetSession({ cookie: data.cookie }) + : iamGetSession({ token: data.jwt }); + + const elapsed = Date.now() - start; + + sessionDuration.add(elapsed); + + const ok = check(result.res, { + 'status 200': (r) => r.status === 200, + 'has user': () => result.body && result.body.user, + }); + + if (ok) { + sessionSuccess.add(1); + } else { + sessionSuccess.add(0); + sessionErrors.add(1); + } + + sleep(0.2); +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +export function teardown() { + console.log('Session load test completed.'); +} diff --git a/load-tests/iam-sign-in.k6.js b/load-tests/iam-sign-in.k6.js new file mode 100644 index 0000000..802a80c --- /dev/null +++ b/load-tests/iam-sign-in.k6.js @@ -0,0 +1,104 @@ +/** + * k6 Load Test: IAM Sign-In Performance + * + * Measures the latency and throughput of POST /iam/sign-in/email. + * Each VU signs in with valid credentials (shared test user). + * + * Run: + * k6 run load-tests/iam-sign-in.k6.js + */ +import { check, sleep } from 'k6'; +import { Trend, Counter, Rate } from 'k6/metrics'; +import { + BASE_URL, + iamSignUp, + iamSignIn, + uniqueEmail, +} from './helpers.js'; + +// --------------------------------------------------------------------------- +// Custom metrics +// --------------------------------------------------------------------------- + +const signInDuration = new Trend('iam_sign_in_duration', true); +const signInErrors = new Counter('iam_sign_in_errors'); +const signInSuccess = new Rate('iam_sign_in_success'); + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export const options = { + scenarios: { + sign_in_load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '10s', target: 50 }, // ramp up + { duration: '1m', target: 50 }, // sustained + { duration: '10s', target: 0 }, // ramp down + ], + gracefulRampDown: '5s', + }, + }, + thresholds: { + iam_sign_in_duration: ['p(95)<2000', 'p(99)<5000'], + iam_sign_in_success: ['rate>0.95'], + http_req_failed: ['rate<0.05'], + }, +}; + +// --------------------------------------------------------------------------- +// Setup – create a shared test user +// --------------------------------------------------------------------------- + +export function setup() { + const email = uniqueEmail('signin'); + const password = 'K6LoadTestPass123'; + const name = 'K6 SignIn User'; + + const result = iamSignUp(email, password, name); + + if (!result.body || (result.res.status !== 200 && result.res.status !== 201)) { + console.error(`Setup: sign-up failed (status ${result.res.status})`); + console.error(result.res.body); + } + + return { email, password }; +} + +// --------------------------------------------------------------------------- +// VU code +// --------------------------------------------------------------------------- + +export default function (data) { + const start = Date.now(); + + const result = iamSignIn(data.email, data.password); + const elapsed = Date.now() - start; + + signInDuration.add(elapsed); + + const ok = check(result.res, { + 'status 200': (r) => r.status === 200, + 'has user': () => result.body && result.body.user, + 'has session/token': () => result.body && (result.body.session || result.body.token), + }); + + if (ok) { + signInSuccess.add(1); + } else { + signInSuccess.add(0); + signInErrors.add(1); + } + + sleep(0.3); // small pause to avoid pure flood +} + +// --------------------------------------------------------------------------- +// Teardown +// --------------------------------------------------------------------------- + +export function teardown() { + console.log('Sign-In load test completed.'); +} diff --git a/load-tests/monitor-memory.sh b/load-tests/monitor-memory.sh new file mode 100755 index 0000000..1f9d3ec --- /dev/null +++ b/load-tests/monitor-memory.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# Memory Monitor for Soak Tests +# +# Usage: +# ./load-tests/monitor-memory.sh # auto-detect node PID +# ./load-tests/monitor-memory.sh # monitor specific PID +# ./load-tests/monitor-memory.sh 5 # custom interval (seconds) +# --------------------------------------------------------------------------- + +set -euo pipefail + +PID="${1:-}" +INTERVAL="${2:-2}" +LOG_FILE="load-tests/results/memory-$(date +%Y%m%d-%H%M%S).csv" + +mkdir -p "$(dirname "$LOG_FILE")" + +# Auto-detect server PID +if [[ -z "$PID" ]]; then + PID=$(pgrep -f "node.*dist/main" 2>/dev/null | head -1 || true) + if [[ -z "$PID" ]]; then + PID=$(pgrep -f "node.*nest-server" 2>/dev/null | head -1 || true) + fi + if [[ -z "$PID" ]]; then + echo "Could not auto-detect server PID. Pass it as argument." + exit 1 + fi + echo "Auto-detected server PID: $PID" +fi + +echo "Monitoring PID $PID every ${INTERVAL}s → $LOG_FILE" +echo "Press Ctrl+C to stop." +echo "" + +# CSV header +echo "timestamp,rss_kb,vsz_kb,elapsed_s" > "$LOG_FILE" + +START=$(date +%s) + +while kill -0 "$PID" 2>/dev/null; do + NOW=$(date +%s) + ELAPSED=$((NOW - START)) + + # RSS and VSZ in KB + MEM=$(ps -o rss=,vsz= -p "$PID" 2>/dev/null || true) + if [[ -z "$MEM" ]]; then + echo "Process $PID exited." + break + fi + + RSS=$(echo "$MEM" | awk '{print $1}') + VSZ=$(echo "$MEM" | awk '{print $2}') + RSS_MB=$((RSS / 1024)) + + echo "${ELAPSED}s RSS: ${RSS_MB} MB (${RSS} KB)" + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ),${RSS},${VSZ},${ELAPSED}" >> "$LOG_FILE" + + sleep "$INTERVAL" +done + +echo "" +echo "Memory log saved to: $LOG_FILE" diff --git a/load-tests/run.sh b/load-tests/run.sh new file mode 100755 index 0000000..6f694a6 --- /dev/null +++ b/load-tests/run.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# k6 Load Test Runner for BetterAuth IAM +# +# Usage: +# ./load-tests/run.sh # run all tests (server must be running) +# ./load-tests/run.sh --with-server # start server, run tests, stop server +# ./load-tests/run.sh sign-in # run a single test +# --------------------------------------------------------------------------- + +set -euo pipefail +cd "$(dirname "$0")/.." + +RESULTS_DIR="load-tests/results" +mkdir -p "$RESULTS_DIR" +TIMESTAMP=$(date +%Y%m%d-%H%M%S) + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +log() { echo -e "${GREEN}[k6]${NC} $*"; } +warn() { echo -e "${YELLOW}[k6]${NC} $*"; } +err() { echo -e "${RED}[k6]${NC} $*"; } + +# --------------------------------------------------------------------------- +# Check prerequisites +# --------------------------------------------------------------------------- + +if ! command -v k6 &> /dev/null; then + err "k6 is not installed. Install via: brew install k6" + exit 1 +fi + +# --------------------------------------------------------------------------- +# Optionally start the server +# --------------------------------------------------------------------------- + +SERVER_PID="" +WITH_SERVER=false + +for arg in "$@"; do + if [[ "$arg" == "--with-server" ]]; then + WITH_SERVER=true + fi +done + +cleanup() { + if [[ -n "$SERVER_PID" ]]; then + log "Stopping server (PID $SERVER_PID) ..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + log "Server stopped." + fi +} +trap cleanup EXIT + +if $WITH_SERVER; then + log "Starting nest-server in CI mode (rate limit disabled for load testing) ..." + NODE_ENV=ci NSC__betterAuth__rateLimit__enabled=false node dist/main.js & + SERVER_PID=$! + + # Wait for health-check + log "Waiting for server to become healthy ..." + for i in $(seq 1 60); do + if curl -s http://127.0.0.1:3000/health > /dev/null 2>&1; then + log "Server is healthy." + break + fi + if [[ $i -eq 60 ]]; then + err "Server did not become healthy within 60 seconds." + exit 1 + fi + sleep 1 + done +fi + +# --------------------------------------------------------------------------- +# Determine which tests to run +# --------------------------------------------------------------------------- + +ALL_TESTS=( + "iam-sign-in" + "iam-graphql-jwt" + "iam-session" + "iam-memory-soak" +) + +TESTS_TO_RUN=() + +for arg in "$@"; do + [[ "$arg" == "--with-server" ]] && continue + # Match partial name + for t in "${ALL_TESTS[@]}"; do + if [[ "$t" == *"$arg"* ]]; then + TESTS_TO_RUN+=("$t") + fi + done +done + +# Default: all tests (except memory soak unless explicitly requested) +if [[ ${#TESTS_TO_RUN[@]} -eq 0 ]]; then + TESTS_TO_RUN=("iam-sign-in" "iam-graphql-jwt" "iam-session") + warn "Skipping memory soak test (10 min). Run explicitly: ./load-tests/run.sh memory-soak" +fi + +# --------------------------------------------------------------------------- +# Run tests +# --------------------------------------------------------------------------- + +FAILED=0 + +for test in "${TESTS_TO_RUN[@]}"; do + SCRIPT="load-tests/${test}.k6.js" + RESULT_FILE="${RESULTS_DIR}/${test}-${TIMESTAMP}.json" + + if [[ ! -f "$SCRIPT" ]]; then + err "Test script not found: $SCRIPT" + FAILED=$((FAILED + 1)) + continue + fi + + log "Running: $test" + log "Results: $RESULT_FILE" + + if k6 run \ + --out "json=$RESULT_FILE" \ + --summary-trend-stats="avg,min,med,max,p(90),p(95),p(99)" \ + "$SCRIPT"; then + log "$test: PASSED" + else + err "$test: FAILED (thresholds not met)" + FAILED=$((FAILED + 1)) + fi + + echo "" +done + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +echo "" +if [[ $FAILED -eq 0 ]]; then + log "All ${#TESTS_TO_RUN[@]} tests passed." +else + err "$FAILED of ${#TESTS_TO_RUN[@]} tests failed." + exit 1 +fi diff --git a/package-lock.json b/package-lock.json index e3cf9b4..9bf39c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@lenne.tech/nest-server", - "version": "11.13.2", + "version": "11.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@lenne.tech/nest-server", - "version": "11.13.2", + "version": "11.13.3", "license": "MIT", "dependencies": { "@apollo/server": "5.4.0", diff --git a/package.json b/package.json index 2b951aa..fa95a21 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lenne.tech/nest-server", - "version": "11.13.2", + "version": "11.13.3", "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).", "keywords": [ "node", @@ -54,10 +54,10 @@ "prepack": "npm run prestart:prod", "prepublishOnly": "npm run lint && npm run test:ci", "preversion": "npm run lint", - "vitest": "NODE_ENV=local vitest run --config vitest-e2e.config.ts", + "vitest": "NODE_ENV=e2e vitest run --config vitest-e2e.config.ts", "vitest:ci": "NODE_ENV=ci vitest run --config vitest-e2e.config.ts", - "vitest:cov": "NODE_ENV=local vitest run --coverage --config vitest-e2e.config.ts", - "vitest:watch": "NODE_ENV=local vitest --config vitest-e2e.config.ts", + "vitest:cov": "NODE_ENV=e2e vitest run --coverage --config vitest-e2e.config.ts", + "vitest:watch": "NODE_ENV=e2e vitest --config vitest-e2e.config.ts", "vitest:unit": "vitest run --config vitest.config.ts", "test:unit:watch": "vitest --config vitest.config.ts", "test:types": "tsc --noEmit --skipLibCheck -p tests/types/tsconfig.json", diff --git a/spectaql.yml b/spectaql.yml index f449649..5fa7cf5 100644 --- a/spectaql.yml +++ b/spectaql.yml @@ -11,7 +11,7 @@ servers: info: title: lT Nest Server description: Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases). - version: 11.13.2 + version: 11.13.3 contact: name: lenne.Tech GmbH url: https://lenne.tech diff --git a/src/config.env.ts b/src/config.env.ts index 0b39f97..0feae6d 100644 --- a/src/config.env.ts +++ b/src/config.env.ts @@ -11,6 +11,145 @@ import { IServerOptions } from './core/common/interfaces/server-options.interfac */ dotenv.config(); const config: { [env: string]: IServerOptions } = { + // =========================================================================== + // CI environment + // =========================================================================== + ci: { + auth: { + legacyEndpoints: { enabled: true }, + }, + automaticObjectIdFiltering: true, + betterAuth: { + // Email verification disabled for test environment (no real mailbox available) + emailVerification: false, + // JWT enabled by default (zero-config) + jwt: { enabled: true, expiresIn: '15m' }, + // Passkey auto-activated when URLs can be resolved (env: 'local' → localhost defaults) + passkey: { enabled: true, origin: 'http://localhost:3001', rpId: 'localhost', rpName: 'Nest Server Local' }, + rateLimit: { enabled: true, max: 100, windowSeconds: 60 }, + secret: 'BETTER_AUTH_SECRET_LOCAL_32_CHARS_M', + // Social providers disabled in local environment (no credentials) + socialProviders: { + apple: { clientId: '', clientSecret: '', enabled: false }, + github: { clientId: '', clientSecret: '', enabled: false }, + google: { clientId: '', clientSecret: '', enabled: false }, + }, + // Trusted origins for Passkey (localhost defaults) + trustedOrigins: ['http://localhost:3000', 'http://localhost:3001'], + // 2FA enabled for local testing + twoFactor: { appName: 'Nest Server Local', enabled: true }, + }, + compression: true, + cookies: false, + cronJobs: { + sayHello: { + cronTime: CronExpression.EVERY_10_SECONDS, + disabled: false, + runOnInit: false, + runParallel: 1, + throwException: false, + timeZone: 'Europe/Berlin', + }, + }, + email: { + defaultSender: { + email: 'oren.satterfield@ethereal.email', + name: 'Nest Server Local', + }, + mailjet: { + api_key_private: 'MAILJET_API_KEY_PRIVATE', + api_key_public: 'MAILJET_API_KEY_PUBLIC', + }, + passwordResetLink: 'http://localhost:4200/user/password-reset', + smtp: { + auth: { + pass: 'K4DvD8U31VKseT7vQC', + user: 'oren.satterfield@ethereal.email', + }, + host: 'mailhog.lenne.tech', + port: 1025, + secure: false, + }, + verificationLink: 'http://localhost:4200/user/verification', + }, + env: 'ci', + // Disable auto-registration to allow Server ErrorCodeModule with SRV_* codes + errorCode: { + autoRegister: false, + }, + execAfterInit: 'npm run docs:bootstrap', + filter: { + maxLimit: null, + }, + graphQl: { + driver: { + introspection: true, + }, + maxComplexity: 1000, + }, + healthCheck: { + configs: { + database: { + enabled: true, + }, + }, + enabled: true, + }, + hostname: '127.0.0.1', + ignoreSelectionsForPopulate: true, + jwt: { + // Each secret should be unique and not reused in other environments, + // also the JWT secret should be different from the Refresh secret! + // crypto.randomBytes(512).toString('base64') (see https://nodejs.org/api/crypto.html#crypto) + refresh: { + renewal: true, + // Each secret should be unique and not reused in other environments, + // also the JWT secret should be different from the Refresh secret! + // crypto.randomBytes(512).toString('base64') (see https://nodejs.org/api/crypto.html#crypto) + secret: 'SECRET_OR_PRIVATE_KEY_LOCAL_REFRESH', + signInOptions: { + expiresIn: '7d', + }, + }, + sameTokenIdPeriod: 2000, + secret: 'SECRET_OR_PRIVATE_KEY_LOCAL', + signInOptions: { + expiresIn: '15m', + }, + }, + loadLocalConfig: true, + logExceptions: true, + mongoose: { + collation: { + locale: 'de', + }, + modelDocumentation: true, + uri: 'mongodb://127.0.0.1/nest-server-ci', + }, + port: 3000, + security: { + checkResponseInterceptor: { + checkObjectItself: false, + debug: false, + ignoreUndefined: true, + mergeRoles: true, + removeUndefinedFromResultArray: true, + throwError: false, + }, + checkSecurityInterceptor: true, + mapAndValidatePipe: true, + }, + sha256: true, + staticAssets: { + options: { prefix: '' }, + path: join(__dirname, '..', 'public'), + }, + templates: { + engine: 'ejs', + path: join(__dirname, 'templates'), + }, + }, + // =========================================================================== // Development environment // =========================================================================== @@ -117,9 +256,9 @@ const config: { [env: string]: IServerOptions } = { }, // =========================================================================== - // Local environment (env: 'local' → auto URLs + Passkey) + // E2E environment // =========================================================================== - local: { + e2e: { auth: { legacyEndpoints: { enabled: true }, }, @@ -177,6 +316,124 @@ const config: { [env: string]: IServerOptions } = { }, verificationLink: 'http://localhost:4200/user/verification', }, + env: 'e2e', + // Disable auto-registration to allow Server ErrorCodeModule with SRV_* codes + errorCode: { + autoRegister: false, + }, + execAfterInit: 'npm run docs:bootstrap', + filter: { + maxLimit: null, + }, + graphQl: { + driver: { + introspection: true, + }, + maxComplexity: 1000, + }, + healthCheck: { + configs: { + database: { + enabled: true, + }, + }, + enabled: true, + }, + hostname: '127.0.0.1', + ignoreSelectionsForPopulate: true, + jwt: { + // Each secret should be unique and not reused in other environments, + // also the JWT secret should be different from the Refresh secret! + // crypto.randomBytes(512).toString('base64') (see https://nodejs.org/api/crypto.html#crypto) + refresh: { + renewal: true, + // Each secret should be unique and not reused in other environments, + // also the JWT secret should be different from the Refresh secret! + // crypto.randomBytes(512).toString('base64') (see https://nodejs.org/api/crypto.html#crypto) + secret: 'SECRET_OR_PRIVATE_KEY_LOCAL_REFRESH', + signInOptions: { + expiresIn: '7d', + }, + }, + sameTokenIdPeriod: 2000, + secret: 'SECRET_OR_PRIVATE_KEY_LOCAL', + signInOptions: { + expiresIn: '15m', + }, + }, + loadLocalConfig: true, + logExceptions: true, + mongoose: { + collation: { + locale: 'de', + }, + modelDocumentation: true, + uri: 'mongodb://127.0.0.1/nest-server-e2e', + }, + port: 3000, + security: { + checkResponseInterceptor: { + checkObjectItself: false, + debug: false, + ignoreUndefined: true, + mergeRoles: true, + removeUndefinedFromResultArray: true, + throwError: false, + }, + checkSecurityInterceptor: true, + mapAndValidatePipe: true, + }, + sha256: true, + staticAssets: { + options: { prefix: '' }, + path: join(__dirname, '..', 'public'), + }, + templates: { + engine: 'ejs', + path: join(__dirname, 'templates'), + }, + }, + + // =========================================================================== + // Local environment (env: 'local' → auto URLs + Passkey) + // =========================================================================== + local: { + auth: { + legacyEndpoints: { enabled: true }, + }, + automaticObjectIdFiltering: true, + compression: true, + cronJobs: { + sayHello: { + cronTime: CronExpression.EVERY_10_SECONDS, + disabled: false, + runOnInit: false, + runParallel: 1, + throwException: false, + timeZone: 'Europe/Berlin', + }, + }, + email: { + defaultSender: { + email: 'oren.satterfield@ethereal.email', + name: 'Nest Server Local', + }, + mailjet: { + api_key_private: 'MAILJET_API_KEY_PRIVATE', + api_key_public: 'MAILJET_API_KEY_PUBLIC', + }, + passwordResetLink: 'http://localhost:4200/user/password-reset', + smtp: { + auth: { + pass: 'K4DvD8U31VKseT7vQC', + user: 'oren.satterfield@ethereal.email', + }, + host: 'mailhog.lenne.tech', + port: 1025, + secure: false, + }, + verificationLink: 'http://localhost:4200/user/verification', + }, env: 'local', // Disable auto-registration to allow Server ErrorCodeModule with SRV_* codes errorCode: { diff --git a/src/core/common/interfaces/server-options.interface.ts b/src/core/common/interfaces/server-options.interface.ts index 2005689..f272d7c 100644 --- a/src/core/common/interfaces/server-options.interface.ts +++ b/src/core/common/interfaces/server-options.interface.ts @@ -524,6 +524,13 @@ export interface IBetterAuthRateLimit { */ max?: number; + /** + * Maximum number of entries in the in-memory rate limit store. + * When exceeded, the oldest entries are evicted to prevent unbounded memory growth. + * @default 10000 + */ + maxEntries?: number; + /** * Custom message when rate limit is exceeded * default: 'Too many requests, please try again later.' diff --git a/src/core/modules/better-auth/better-auth.config.ts b/src/core/modules/better-auth/better-auth.config.ts index dec4bfa..a4121a0 100644 --- a/src/core/modules/better-auth/better-auth.config.ts +++ b/src/core/modules/better-auth/better-auth.config.ts @@ -14,6 +14,21 @@ import { IBetterAuth } from '../../common/interfaces/server-options.interface'; */ export type BetterAuthInstance = ReturnType; +// --------------------------------------------------------------------------- +// Performance-optimized password hashing using Node.js native crypto.scrypt +// +// Better-Auth's default uses @noble/hashes scrypt which runs on the main +// event loop. Under concurrent load this blocks all requests while hashing. +// Node.js crypto.scrypt() offloads the work to the libuv thread pool, +// allowing the event loop to remain responsive. +// +// Parameters match Better-Auth's defaults exactly: +// N=16384, r=16, p=1, dkLen=64, 16-byte salt +// --------------------------------------------------------------------------- + +const SCRYPT_PARAMS = { maxmem: 128 * 16384 * 16 * 2, N: 16384, p: 1, r: 16 }; +const SCRYPT_KEY_LENGTH = 64; + /** * Generates a cryptographically secure random secret. * Used as fallback when no BETTER_AUTH_SECRET is configured. @@ -27,6 +42,38 @@ function generateSecureSecret(): string { return crypto.randomBytes(32).toString('base64'); } +/** + * Hash a password using Node.js native crypto.scrypt (libuv thread pool). + * Output format matches Better-Auth: "salt:hash" (both hex encoded). + */ +async function nativeScryptHash(password: string): Promise { + const salt = crypto.randomBytes(16).toString('hex'); + const normalized = password.normalize('NFKC'); + const key = await new Promise((resolve, reject) => { + crypto.scrypt(normalized, salt, SCRYPT_KEY_LENGTH, SCRYPT_PARAMS, (err, derivedKey) => { + if (err) reject(err); + else resolve(derivedKey); + }); + }); + return `${salt}:${key.toString('hex')}`; +} + +/** + * Verify a password against a Better-Auth scrypt hash using Node.js native crypto.scrypt. + */ +async function nativeScryptVerify(data: { hash: string; password: string }): Promise { + const [salt, storedKey] = data.hash.split(':'); + if (!salt || !storedKey) return false; + const normalized = data.password.normalize('NFKC'); + const key = await new Promise((resolve, reject) => { + crypto.scrypt(normalized, salt, SCRYPT_KEY_LENGTH, SCRYPT_PARAMS, (err, derivedKey) => { + if (err) reject(err); + else resolve(derivedKey); + }); + }); + return crypto.timingSafeEqual(key, Buffer.from(storedKey, 'hex')); +} + /** * Cached auto-generated secret for the current server instance. * Generated once at a module load to ensure consistency within a single run. @@ -268,6 +315,10 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Bett // Can be disabled by setting config.emailAndPassword.enabled = false emailAndPassword: { enabled: config.emailAndPassword?.enabled !== false, + password: { + hash: nativeScryptHash, + verify: nativeScryptVerify, + }, }, plugins, secret: validation.resolvedSecret || config.secret, diff --git a/src/core/modules/better-auth/core-better-auth-email-verification.service.ts b/src/core/modules/better-auth/core-better-auth-email-verification.service.ts index e1df6c3..8604161 100644 --- a/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +++ b/src/core/modules/better-auth/core-better-auth-email-verification.service.ts @@ -409,11 +409,27 @@ export class CoreBetterAuthEmailVerificationService { return elapsed < cooldown; } + /** + * Maximum entries in the lastSendTimes map to prevent unbounded growth. + * At 10,000 entries with email strings as keys, this uses ~1-2 MB max. + */ + private static readonly MAX_SEND_TIMES_ENTRIES = 10000; + /** * Track that a verification email was sent to this address */ protected trackSend(email: string): void { const key = email.toLowerCase(); + + // Evict oldest entry if map is at capacity (before adding new one) + if (!this.lastSendTimes.has(key) && this.lastSendTimes.size >= CoreBetterAuthEmailVerificationService.MAX_SEND_TIMES_ENTRIES) { + // Map preserves insertion order - first key is the oldest + const oldestKey = this.lastSendTimes.keys().next().value; + if (oldestKey) { + this.lastSendTimes.delete(oldestKey); + } + } + this.lastSendTimes.set(key, Date.now()); // Schedule cleanup to prevent memory leak diff --git a/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts b/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts index 54f3931..89ec97e 100644 --- a/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +++ b/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts @@ -46,6 +46,7 @@ interface RateLimitEntry { const DEFAULT_CONFIG: Required = { enabled: false, max: 10, + maxEntries: 10000, message: 'Too many requests, please try again later.', skipEndpoints: ['/session', '/callback'], strictEndpoints: ['/sign-in', '/sign-up', '/forgot-password', '/reset-password'], @@ -143,6 +144,11 @@ export class CoreBetterAuthRateLimiter { let entry = this.store.get(key); if (!entry || now >= entry.resetTime) { + // Evict oldest entries if store exceeds maxEntries + if (!entry && this.store.size >= this.config.maxEntries) { + this.evictOldest(); + } + // Create new entry or reset expired one entry = { count: 1, @@ -234,6 +240,40 @@ export class CoreBetterAuthRateLimiter { } } + /** + * Evict the oldest entries when the store exceeds maxEntries. + * First removes all expired entries, then removes entries closest to expiry + * until the store is at 90% capacity. + */ + private evictOldest(): void { + const now = Date.now(); + let evicted = 0; + + // First pass: remove all expired entries + for (const [key, entry] of this.store.entries()) { + if (now >= entry.resetTime) { + this.store.delete(key); + evicted++; + } + } + + // If still over limit, remove entries with earliest resetTime (oldest) + if (this.store.size >= this.config.maxEntries) { + const targetSize = Math.floor(this.config.maxEntries * 0.9); + const entries = [...this.store.entries()].sort((a, b) => a[1].resetTime - b[1].resetTime); + + for (const [key] of entries) { + if (this.store.size <= targetSize) break; + this.store.delete(key); + evicted++; + } + } + + if (evicted > 0) { + this.logger.warn(`Evicted ${evicted} rate limit entries (store was at capacity: ${this.config.maxEntries})`); + } + } + /** * Determine if an endpoint should skip rate limiting */ diff --git a/src/core/modules/better-auth/core-better-auth-user.mapper.ts b/src/core/modules/better-auth/core-better-auth-user.mapper.ts index 4ea525d..1be5fae 100644 --- a/src/core/modules/better-auth/core-better-auth-user.mapper.ts +++ b/src/core/modules/better-auth/core-better-auth-user.mapper.ts @@ -370,27 +370,38 @@ export class CoreBetterAuthUserMapper { return false; } + // Better-Auth stores account.userId as ObjectId that references users._id + const userMongoId = legacyUser._id as ObjectId; + + // FAST PATH: Check if credential account already exists BEFORE expensive bcrypt + // For already-migrated users this avoids ~130ms of bcrypt.compare() per sign-in + const existingAccount = await accountsCollection.findOne({ + providerId: 'credential', + userId: userMongoId, + }); + + if (existingAccount) { + return true; + } + + // No password provided - cannot verify, cannot migrate + if (!plainPassword) { + return false; + } + // IMPORTANT: Verify the provided password matches the legacy hash // This prevents migration with a wrong password // Legacy Auth uses two formats for backwards compatibility: // 1. bcrypt(password) - direct hash // 2. bcrypt(sha256(password)) - sha256 then bcrypt - if (plainPassword) { - const directMatch = await bcrypt.compare(plainPassword, legacyUser.password); - const sha256Match = await bcrypt.compare(sha256(plainPassword), legacyUser.password); - if (!directMatch && !sha256Match) { - // Security: Wrong password provided for migration - reject - this.logger.warn(`Migration password verification failed for ${maskEmail(userEmail)}`); - return false; - } - } else { - // No password provided - cannot verify, cannot migrate + const directMatch = await bcrypt.compare(plainPassword, legacyUser.password); + const sha256Match = !directMatch ? await bcrypt.compare(sha256(plainPassword), legacyUser.password) : false; + if (!directMatch && !sha256Match) { + // Security: Wrong password provided for migration - reject + this.logger.warn(`Migration password verification failed for ${maskEmail(userEmail)}`); return false; } - // Better-Auth stores account.userId as ObjectId that references users._id - // The id field is a secondary string identifier used in API responses - const userMongoId = legacyUser._id as ObjectId; const userIdHex = userMongoId.toHexString(); // Update user with Better-Auth fields if not already present @@ -413,17 +424,6 @@ export class CoreBetterAuthUserMapper { ); } - // Check if credential account already exists - // Better-Auth stores userId as ObjectId referencing users._id - const existingAccount = await accountsCollection.findOne({ - providerId: 'credential', - userId: userMongoId, - }); - - if (existingAccount) { - return true; - } - // Create the credential account with Better-Auth compatible scrypt hash const passwordHash = await this.hashPasswordForBetterAuth(plainPassword);