From a5088ce64f11aeca6a5557637359bb296b0d3884 Mon Sep 17 00:00:00 2001 From: devsimze Date: Tue, 23 Jun 2026 22:55:53 +0100 Subject: [PATCH] backend: harden SEP-10 auth and SEP-12 KYC rate limits Enhance SEP-10 error recovery, conduct security audit remediation, and implement distributed rate limiting for challenge/verify flows. Closes #587, #588, #589, #733. Co-authored-by: Cursor --- backend/.env.example | 14 + backend/SEP10_AUTH_SECURITY_AUDIT.md | 85 +++++ backend/src/app.js | 13 +- backend/src/lib/rate-limit.js | 78 ++++ backend/src/lib/rate-limit.test.js | 31 ++ backend/src/lib/sep10-auth.js | 191 ++++++++-- backend/src/lib/sep10-auth.test.js | 77 +++- backend/src/routes/auth.js | 486 +++++++++++-------------- backend/src/routes/auth.routes.test.js | 153 ++++++++ backend/src/routes/sep12.test.js | 32 ++ 10 files changed, 845 insertions(+), 315 deletions(-) create mode 100644 backend/SEP10_AUTH_SECURITY_AUDIT.md create mode 100644 backend/src/routes/auth.routes.test.js diff --git a/backend/.env.example b/backend/.env.example index a7762d6e..2d02fb33 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -25,6 +25,20 @@ STELLAR_HORIZON_URL= # SEP-0010 Web Authentication # Generate with: node -e "console.log(require('stellar-sdk').Keypair.random().secret())" SEP10_SERVER_SIGNING_KEY=your_secret_key_here +HOME_DOMAIN=localhost +JWT_SECRET=your_jwt_secret_here + +# SEP-10 rate limiting (optional overrides) +SEP10_CHALLENGE_RATE_LIMIT_WINDOW_MS=60000 +SEP10_CHALLENGE_RATE_LIMIT_MAX=20 +SEP10_VERIFY_RATE_LIMIT_WINDOW_MS=60000 +SEP10_VERIFY_RATE_LIMIT_MAX=10 + +# SEP-12 KYC rate limiting (optional overrides) +SEP12_RATE_LIMIT_WINDOW_MS=900000 +SEP12_RATE_LIMIT_MAX=50 +SEP12_RATE_LIMIT_WRITE_WINDOW_MS=3600000 +SEP12_RATE_LIMIT_WRITE_MAX=10 # Optional asset issuers USDC_ISSUER=your_usdc_issuer diff --git a/backend/SEP10_AUTH_SECURITY_AUDIT.md b/backend/SEP10_AUTH_SECURITY_AUDIT.md new file mode 100644 index 00000000..038214aa --- /dev/null +++ b/backend/SEP10_AUTH_SECURITY_AUDIT.md @@ -0,0 +1,85 @@ +# SEP-10 Authentication Security Audit + +**Module:** `backend/src/lib/sep10-auth.js`, `backend/src/routes/auth.js` +**Issues:** #588 (audit), #587 (error recovery), #733 (rate limiting) +**Date:** 2026-06-23 + +## Scope + +This audit covers the SEP-0010 Web Authentication flow: challenge generation, signed transaction verification, merchant lookup, and session token issuance. + +## Threat Model + +| Threat | Mitigation | Status | +|--------|------------|--------| +| Challenge replay | In-memory nonce cache rejects reused nonces | ✅ Implemented | +| Oversized/malformed XDR | `validateChallengeXdr` enforces size (8 KB) and base64 charset | ✅ Implemented | +| Home-domain spoofing | Challenge and verify both use `getHomeDomain()`; mismatch returns `HOME_DOMAIN_MISMATCH` | ✅ Fixed | +| Missing server/client signatures | Both signatures verified against transaction hash | ✅ Implemented | +| Expired challenges | Time bounds checked against server clock | ✅ Implemented | +| Brute-force challenge/verify | Per-account+IP challenge limits; per-IP verify limits (#733) | ✅ Implemented | +| JWT secret fallback | `JWT_SECRET` required at runtime; no default secret | ✅ Implemented | +| Store outage during verify | Transient Supabase errors retried; retryable 503 returned (#587) | ✅ Implemented | +| Information leakage via errors | Generic `AUTHENTICATION_FAILED` for parse failures; structured codes for known cases | ✅ Implemented | + +## Findings & Remediation + +### High — Home domain inconsistency (fixed) + +**Issue:** Challenge generation defaulted to `localhost` while verification used `process.env.HOME_DOMAIN`, allowing valid-looking challenges to fail verification in production. + +**Fix:** Centralized domain resolution in `getHomeDomain()` and used it in both `generateChallenge` and `verifyChallenge`. + +### Medium — Missing XDR size guard (fixed) + +**Issue:** Unbounded XDR input could be used for DoS via expensive parsing. + +**Fix:** `MAX_CHALLENGE_XDR_BYTES` (8192) enforced before Stellar SDK parsing. + +### Medium — No structured error recovery on merchant lookup (fixed) + +**Issue:** Transient database errors surfaced as opaque 500 responses. + +**Fix:** `lookupMerchantByStellarAddress` wraps Supabase calls with `withSep10StoreRecovery`, returning retryable `503 SERVICE_UNAVAILABLE`. + +### Low — Generic catch in verifyChallenge (accepted) + +**Issue:** Unexpected parse errors return a generic message without leaking SDK internals. + +**Status:** Accepted — intentional fail-closed behavior. + +## Rate Limiting (#733) + +| Endpoint | Key | Default window | Default max | +|----------|-----|----------------|-------------| +| `POST /api/auth/challenge` | `sep10:challenge:{account}:{ip}` | 60s | 20 | +| `POST /api/auth/verify` | `sep10:verify:{ip}` | 60s | 10 | + +Redis-backed store (`rl:sep10:` prefix) is used when `REDIS_URL` is available; in-memory fallback otherwise. + +### Environment variables + +``` +SEP10_CHALLENGE_RATE_LIMIT_WINDOW_MS=60000 +SEP10_CHALLENGE_RATE_LIMIT_MAX=20 +SEP10_VERIFY_RATE_LIMIT_WINDOW_MS=60000 +SEP10_VERIFY_RATE_LIMIT_MAX=10 +``` + +## Recommendations (future work) + +1. **Distributed nonce store:** Replace in-process nonce cache with Redis for multi-instance deployments. +2. **Audit logging:** Include SEP-10 error codes in login audit events for security monitoring. +3. **Challenge binding:** Optionally bind challenges to a client-supplied `client_domain` query param per SEP-10 spec. + +## Test Coverage + +- `backend/src/lib/sep10-auth.test.js` — nonce replay, home domain, XDR validation, store recovery +- `backend/src/routes/auth.routes.test.js` — rate limits, retryable 503 on store failure +- `backend/src/lib/rate-limit.test.js` — SEP-10 key generation and limiter factories + +## Security Assumptions + +- `SEP10_SERVER_SIGNING_KEY` and `JWT_SECRET` are stored securely and rotated periodically. +- `HOME_DOMAIN` matches the domain published in `stellar.toml`. +- Redis (when used) is network-isolated and authenticated. diff --git a/backend/src/app.js b/backend/src/app.js index b187d38b..32c566bd 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -19,7 +19,7 @@ import createSep12Router from "./routes/sep12.js"; import trustlinesRouter from "./routes/trustlines.js"; import paymentDetailsRouter from "./routes/paymentDetails.js"; import x402Router from "./routes/x402.js"; -import authRouter from "./routes/auth.js"; +import createAuthRouter from "./routes/auth.js"; import { requireApiKeyAuth } from "./lib/auth.js"; import { isHorizonReachable } from "./lib/stellar.js"; @@ -33,6 +33,8 @@ import { createRedisRateLimitStore, createVerifyPaymentRateLimit, createMerchantRegistrationRateLimit, + createSep10ChallengeRateLimit, + createSep10VerifyRateLimit, } from "./lib/rate-limit.js"; import { versionDeprecationMiddleware } from "./lib/version-deprecation.js"; @@ -252,6 +254,15 @@ export async function createApp({ redisClient }) { store: redisAvailable ? createRedisRateLimitStore({ client: redisClient }) : undefined, }); + const sep10RateLimitStore = redisAvailable + ? createRedisRateLimitStore({ client: redisClient, prefix: "rl:sep10:" }) + : undefined; + + const authRouter = createAuthRouter({ + sep10ChallengeRateLimit: createSep10ChallengeRateLimit({ store: sep10RateLimitStore }), + sep10VerifyRateLimit: createSep10VerifyRateLimit({ store: sep10RateLimitStore }), + }); + // x402 pay-per-request on payment creation endpoints (custom middleware flow) const x402Provider = process.env.X402_PROVIDER_PUBLIC_KEY; const x402Enabled = Boolean(x402Provider && process.env.X402_JWT_SECRET); diff --git a/backend/src/lib/rate-limit.js b/backend/src/lib/rate-limit.js index 62eb14d0..ddd315d6 100644 --- a/backend/src/lib/rate-limit.js +++ b/backend/src/lib/rate-limit.js @@ -7,6 +7,18 @@ export const VERIFY_PAYMENT_RATE_LIMIT_WINDOW_MS = 60 * 1000; export const VERIFY_PAYMENT_RATE_LIMIT_MAX = 30; export const MERCHANT_SECURITY_ACTION_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; export const MERCHANT_SECURITY_ACTION_RATE_LIMIT_MAX = 10; +export const SEP10_CHALLENGE_RATE_LIMIT_WINDOW_MS = Number( + process.env.SEP10_CHALLENGE_RATE_LIMIT_WINDOW_MS || 60 * 1000, +); +export const SEP10_CHALLENGE_RATE_LIMIT_MAX = Number( + process.env.SEP10_CHALLENGE_RATE_LIMIT_MAX || 20, +); +export const SEP10_VERIFY_RATE_LIMIT_WINDOW_MS = Number( + process.env.SEP10_VERIFY_RATE_LIMIT_WINDOW_MS || 60 * 1000, +); +export const SEP10_VERIFY_RATE_LIMIT_MAX = Number( + process.env.SEP10_VERIFY_RATE_LIMIT_MAX || 10, +); function setStandardRateLimitHeaders(res, rateLimitState) { if (!res || !rateLimitState) { @@ -120,6 +132,72 @@ export function createMerchantSecurityActionRateLimit({ }); } +export function getSep10ChallengeRateLimitKey(req) { + const account = + typeof req?.body?.account === "string" && req.body.account.trim().length > 0 + ? req.body.account.trim() + : "unknown-account"; + const ipKey = ipKeyGenerator(req?.ip ?? req?.socket?.remoteAddress ?? "unknown-ip"); + return `sep10:challenge:${account}:${ipKey}`; +} + +export function getSep10VerifyRateLimitKey(req) { + const ipKey = ipKeyGenerator(req?.ip ?? req?.socket?.remoteAddress ?? "unknown-ip"); + return `sep10:verify:${ipKey}`; +} + +export function createSep10ChallengeRateLimit({ + store, + rateLimitFactory = rateLimit, + max = SEP10_CHALLENGE_RATE_LIMIT_MAX, + windowMs = SEP10_CHALLENGE_RATE_LIMIT_WINDOW_MS, +} = {}) { + return rateLimitFactory({ + windowMs, + max, + message: { + error: "Too many challenge requests, please try again later.", + code: "SEP10_RATE_LIMITED", + }, + standardHeaders: true, + legacyHeaders: false, + validate: { ip: false }, + keyGenerator: getSep10ChallengeRateLimitKey, + handler: (req, res, _next, options) => { + setStandardRateLimitHeaders(res, req.rateLimit); + res.status(options.statusCode).json(options.message); + }, + store, + passOnStoreError: true, + }); +} + +export function createSep10VerifyRateLimit({ + store, + rateLimitFactory = rateLimit, + max = SEP10_VERIFY_RATE_LIMIT_MAX, + windowMs = SEP10_VERIFY_RATE_LIMIT_WINDOW_MS, +} = {}) { + return rateLimitFactory({ + windowMs, + max, + message: { + error: "Too many verification attempts, please try again later.", + code: "SEP10_RATE_LIMITED", + }, + standardHeaders: true, + legacyHeaders: false, + validate: { ip: false }, + keyGenerator: getSep10VerifyRateLimitKey, + handler: (req, res, _next, options) => { + setStandardRateLimitHeaders(res, req.rateLimit); + res.status(options.statusCode).json(options.message); + }, + store, + passOnStoreError: true, + }); +} + export function createMerchantRegistrationRateLimit({ store, rateLimitFactory = rateLimit, diff --git a/backend/src/lib/rate-limit.test.js b/backend/src/lib/rate-limit.test.js index 8c91c167..7674aa71 100644 --- a/backend/src/lib/rate-limit.test.js +++ b/backend/src/lib/rate-limit.test.js @@ -11,12 +11,18 @@ vi.mock("redis", () => ({ import { createMerchantSecurityActionRateLimit, createRedisRateLimitStore, + createSep10ChallengeRateLimit, + createSep10VerifyRateLimit, createVerifyPaymentRateLimit, getMerchantSecurityActionRateLimitKey, + getSep10ChallengeRateLimitKey, + getSep10VerifyRateLimitKey, getVerifyPaymentRateLimitKey, MERCHANT_SECURITY_ACTION_RATE_LIMIT_MAX, MERCHANT_SECURITY_ACTION_RATE_LIMIT_WINDOW_MS, RATE_LIMIT_REDIS_PREFIX, + SEP10_CHALLENGE_RATE_LIMIT_MAX, + SEP10_VERIFY_RATE_LIMIT_MAX, VERIFY_PAYMENT_RATE_LIMIT_MAX, VERIFY_PAYMENT_RATE_LIMIT_WINDOW_MS, } from "./rate-limit.js"; @@ -150,6 +156,31 @@ describe("getMerchantSecurityActionRateLimitKey", () => { }); }); +describe("SEP-10 rate limiters", () => { + it("builds challenge keys scoped to account and IP", () => { + const key = getSep10ChallengeRateLimitKey({ + body: { account: "GABC" }, + ip: "198.51.100.2", + }); + expect(key).toBe("sep10:challenge:GABC:198.51.100.2"); + }); + + it("builds verify keys scoped to client IP", () => { + const key = getSep10VerifyRateLimitKey({ ip: "198.51.100.2" }); + expect(key).toBe("sep10:verify:198.51.100.2"); + }); + + it("creates challenge and verify limiters with configured defaults", () => { + const challenge = createSep10ChallengeRateLimit(); + const verify = createSep10VerifyRateLimit(); + + expect(challenge).toBeDefined(); + expect(verify).toBeDefined(); + expect(SEP10_CHALLENGE_RATE_LIMIT_MAX).toBeGreaterThan(0); + expect(SEP10_VERIFY_RATE_LIMIT_MAX).toBeGreaterThan(0); + }); +}); + describe("redis client helpers", () => { beforeEach(() => { resetRedisClientForTests(); diff --git a/backend/src/lib/sep10-auth.js b/backend/src/lib/sep10-auth.js index 30c18c98..4d928eda 100644 --- a/backend/src/lib/sep10-auth.js +++ b/backend/src/lib/sep10-auth.js @@ -1,6 +1,7 @@ import jwt from "jsonwebtoken"; import * as StellarSdk from "stellar-sdk"; import { randomBytes } from "node:crypto"; +import { logger } from "./logger.js"; const DEFAULT_HOME_DOMAIN = "localhost"; @@ -10,9 +11,42 @@ const NETWORK_PASSPHRASE = ? StellarSdk.Networks.PUBLIC : StellarSdk.Networks.TESTNET; -const CHALLENGE_EXPIRES_IN = 300; +export const CHALLENGE_EXPIRES_IN = 300; +export const MAX_CHALLENGE_XDR_BYTES = 8192; +export const MIN_CHALLENGE_NONCE_LENGTH = 16; + const NONCE_CLEANUP_INTERVAL = 600_000; const MAX_NONCE_CACHE = 10_000; +const STORE_RETRY_DELAYS_MS = [100, 300]; + +const _usedNonces = new Set(); +let _nonceCleanupTimer = null; + +/** + * Structured error for SEP-10 route/store failures (#587). + */ +export class Sep10AuthError extends Error { + constructor(code, message, httpStatus = 400, { retryable = false, cause } = {}) { + super(message); + this.name = "Sep10AuthError"; + this.code = code; + this.httpStatus = httpStatus; + this.retryable = retryable; + if (cause) this.cause = cause; + } +} + +export function getHomeDomain() { + const configured = process.env.HOME_DOMAIN; + if (typeof configured === "string" && configured.trim().length > 0) { + return configured.trim(); + } + return DEFAULT_HOME_DOMAIN; +} + +export function getNetworkPassphrase() { + return NETWORK_PASSPHRASE; +} function getJwtSecret() { const secret = process.env.JWT_SECRET; @@ -22,9 +56,6 @@ function getJwtSecret() { return secret; } -const _usedNonces = new Set(); -let _nonceCleanupTimer = null; - function startNonceCleanup() { if (_nonceCleanupTimer) return; _nonceCleanupTimer = setInterval(() => { @@ -54,7 +85,71 @@ function getServerSigningKey() { return process.env.SEP10_SERVER_SIGNING_KEY; } -export function generateChallenge(clientAccountId, homeDomain = DEFAULT_HOME_DOMAIN) { +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function isRetryableSep10StoreError(error) { + if (!error) return false; + const message = String(error.message || ""); + return ( + /fetch failed|timeout|ECONNRESET|ETIMEDOUT|502|503|504|temporarily unavailable/i.test( + message, + ) || error.code === "PGRST000" + ); +} + +/** + * Retry transient store failures before surfacing a retryable 503 (#587). + */ +export async function withSep10StoreRecovery(fn, label) { + let lastError = null; + + for (let attempt = 0; attempt <= STORE_RETRY_DELAYS_MS.length; attempt += 1) { + try { + return await fn(); + } catch (err) { + lastError = err; + if (!isRetryableSep10StoreError(err) || attempt === STORE_RETRY_DELAYS_MS.length) { + if (isRetryableSep10StoreError(err)) { + logger.warn({ label, attempt }, "sep10 store temporarily unavailable"); + throw new Sep10AuthError( + "SERVICE_UNAVAILABLE", + "Authentication store temporarily unavailable, please retry", + 503, + { retryable: true, cause: err }, + ); + } + throw err; + } + await sleep(STORE_RETRY_DELAYS_MS[attempt]); + } + } + + throw lastError; +} + +/** + * Guard against oversized or malformed challenge XDR before parsing (#588). + */ +export function validateChallengeXdr(challengeXdr) { + if (typeof challengeXdr !== "string" || challengeXdr.trim().length === 0) { + return { valid: false, error: "Missing challenge transaction" }; + } + + const trimmed = challengeXdr.trim(); + if (trimmed.length > MAX_CHALLENGE_XDR_BYTES) { + return { valid: false, error: "Challenge transaction exceeds maximum size" }; + } + + if (!/^[A-Za-z0-9+/=]+$/.test(trimmed)) { + return { valid: false, error: "Invalid challenge transaction encoding" }; + } + + return { valid: true }; +} + +export function generateChallenge(clientAccountId, homeDomain = getHomeDomain()) { const serverSigningKey = getServerSigningKey(); if (!serverSigningKey) { @@ -99,22 +194,25 @@ export function generateChallenge(clientAccountId, homeDomain = DEFAULT_HOME_DOM } /** - * Verify a signed SEP-0010 challenge transaction - * @param {string} challengeXdr - Base64-encoded signed transaction XDR - * @param {string} clientAccountId - Expected client account ID - * @returns {{ valid: boolean, error?: string }} + * Verify a signed SEP-0010 challenge transaction. + * @returns {{ valid: boolean, error?: string, code?: string }} */ -export function verifyChallenge(challengeXdr, clientAccountId, homeDomain = DEFAULT_HOME_DOMAIN) { +export function verifyChallenge(challengeXdr, clientAccountId, homeDomain = getHomeDomain()) { const serverSigningKey = getServerSigningKey(); if (!serverSigningKey) { - return { valid: false, error: "SEP-0010 not configured" }; + return { valid: false, error: "SEP-0010 not configured", code: "NOT_CONFIGURED" }; + } + + const xdrValidation = validateChallengeXdr(challengeXdr); + if (!xdrValidation.valid) { + return { valid: false, error: xdrValidation.error, code: "INVALID_XDR" }; } try { StellarSdk.Keypair.fromPublicKey(clientAccountId); } catch { - return { valid: false, error: "Invalid client account" }; + return { valid: false, error: "Invalid client account", code: "INVALID_ACCOUNT" }; } try { @@ -125,44 +223,43 @@ export function verifyChallenge(challengeXdr, clientAccountId, homeDomain = DEFA ); if (transaction.operations.length !== 1) { - return { valid: false, error: "Invalid challenge structure" }; + return { valid: false, error: "Invalid challenge structure", code: "INVALID_STRUCTURE" }; } const operation = transaction.operations[0]; if (operation.type !== "manageData") { - return { valid: false, error: "Invalid operation type" }; + return { valid: false, error: "Invalid operation type", code: "INVALID_OPERATION" }; } if (operation.source !== clientAccountId) { - return { valid: false, error: "Client account mismatch" }; + return { valid: false, error: "Client account mismatch", code: "ACCOUNT_MISMATCH" }; } const expectedName = `${homeDomain} auth`; if (operation.name !== expectedName) { - return { valid: false, error: "Challenge data name mismatch" }; + return { valid: false, error: "Challenge data name mismatch", code: "HOME_DOMAIN_MISMATCH" }; } - const valueStr = typeof operation.value === "string" ? operation.value : operation.value?.toString(); - if (typeof valueStr !== "string" || valueStr.length < 16) { - return { valid: false, error: "Invalid challenge nonce" }; + const valueStr = + typeof operation.value === "string" ? operation.value : operation.value?.toString(); + if (typeof valueStr !== "string" || valueStr.length < MIN_CHALLENGE_NONCE_LENGTH) { + return { valid: false, error: "Invalid challenge nonce", code: "INVALID_NONCE" }; } if (isNonceReused(valueStr)) { - return { valid: false, error: "Challenge nonce already used" }; + return { valid: false, error: "Challenge nonce already used", code: "NONCE_REPLAY" }; } const now = Math.floor(Date.now() / 1000); const { minTime, maxTime } = transaction.timeBounds; if (now < parseInt(minTime, 10) || now > parseInt(maxTime, 10)) { - return { valid: false, error: "Challenge expired" }; + return { valid: false, error: "Challenge expired", code: "CHALLENGE_EXPIRED" }; } const txHash = transaction.hash(); - const serverKeypairForVerify = StellarSdk.Keypair.fromSecret( - serverSigningKey, - ); + const serverKeypairForVerify = StellarSdk.Keypair.fromSecret(serverSigningKey); const serverSigned = transaction.signatures.some((sig) => { try { return serverKeypairForVerify.verify(txHash, sig.signature()); @@ -172,7 +269,7 @@ export function verifyChallenge(challengeXdr, clientAccountId, homeDomain = DEFA }); if (!serverSigned) { - return { valid: false, error: "Server signature missing" }; + return { valid: false, error: "Server signature missing", code: "SERVER_SIGNATURE_MISSING" }; } const clientKeypair = StellarSdk.Keypair.fromPublicKey(clientAccountId); @@ -185,21 +282,43 @@ export function verifyChallenge(challengeXdr, clientAccountId, homeDomain = DEFA }); if (!clientSigned) { - return { valid: false, error: "Client signature missing or invalid" }; + return { + valid: false, + error: "Client signature missing or invalid", + code: "CLIENT_SIGNATURE_INVALID", + }; } return { valid: true }; - } catch (err) { - return { valid: false, error: "Authentication failed" }; + } catch { + return { valid: false, error: "Authentication failed", code: "AUTHENTICATION_FAILED" }; } } /** - * Generate a JWT session token for authenticated merchant - * @param {string} merchantId - Merchant UUID - * @param {string} email - Merchant's email or Stellar address - * @returns {string} JWT token + * Look up a merchant by Stellar recipient with transient-error recovery (#587). */ +export async function lookupMerchantByStellarAddress(clientAccount, supabaseClient) { + return withSep10StoreRecovery(async () => { + const { data, error } = await supabaseClient + .from("merchants") + .select("id, email, business_name, notification_email") + .eq("recipient", clientAccount) + .is("deleted_at", null) + .maybeSingle(); + + if (error) { + if (isRetryableSep10StoreError(error)) { + throw error; + } + error.status = 500; + throw error; + } + + return data; + }, "sep10_merchant_lookup"); +} + export function generateSessionToken(merchantId, email) { return jwt.sign( { @@ -212,17 +331,11 @@ export function generateSessionToken(merchantId, email) { ); } -/** - * Verify and decode a session token - * @param {string} token - Session token - * @returns {{ valid: boolean, payload?: object, error?: string }} - */ export function verifySessionToken(token) { try { const payload = jwt.verify(token, getJwtSecret()); return { valid: true, payload }; - } catch (err) { + } catch { return { valid: false, error: "Invalid or expired session token" }; } } - diff --git a/backend/src/lib/sep10-auth.test.js b/backend/src/lib/sep10-auth.test.js index d554fc11..42600936 100644 --- a/backend/src/lib/sep10-auth.test.js +++ b/backend/src/lib/sep10-auth.test.js @@ -1,8 +1,15 @@ -import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; import * as StellarSdk from "stellar-sdk"; import { generateChallenge, verifyChallenge, + validateChallengeXdr, + getHomeDomain, + isRetryableSep10StoreError, + withSep10StoreRecovery, + lookupMerchantByStellarAddress, + Sep10AuthError, + MAX_CHALLENGE_XDR_BYTES, _resetNonceCacheForTests, } from "./sep10-auth.js"; @@ -100,5 +107,73 @@ describe("SEP-0010 Authentication", () => { it("should reject an invalid client account in verifyChallenge", () => { const result = verifyChallenge("AAAA", "not-a-key", HOME_DOMAIN); expect(result.valid).toBe(false); + expect(result.code).toBe("INVALID_ACCOUNT"); + }); + + it("rejects oversized challenge XDR before parsing", () => { + const oversized = "A".repeat(MAX_CHALLENGE_XDR_BYTES + 1); + const result = verifyChallenge(oversized, clientKeypair.publicKey(), HOME_DOMAIN); + expect(result.valid).toBe(false); + expect(result.code).toBe("INVALID_XDR"); + }); + + it("rejects home-domain mismatch between challenge and verify", () => { + const challengeXdr = generateChallenge(clientKeypair.publicKey(), "example.com"); + const tx = StellarSdk.TransactionBuilder.fromXDR( + challengeXdr, + StellarSdk.Networks.TESTNET, + ); + tx.sign(clientKeypair); + + const result = verifyChallenge(tx.toXDR(), clientKeypair.publicKey(), HOME_DOMAIN); + expect(result.valid).toBe(false); + expect(result.code).toBe("HOME_DOMAIN_MISMATCH"); + }); + + it("validateChallengeXdr rejects non-base64 payloads", () => { + expect(validateChallengeXdr("not valid!!!")).toEqual({ + valid: false, + error: "Invalid challenge transaction encoding", + }); + }); + + it("getHomeDomain falls back to localhost when unset", () => { + delete process.env.HOME_DOMAIN; + expect(getHomeDomain()).toBe("localhost"); + process.env.HOME_DOMAIN = HOME_DOMAIN; + }); + + it("isRetryableSep10StoreError detects transient upstream failures", () => { + expect(isRetryableSep10StoreError({ message: "fetch failed: timeout" })).toBe(true); + expect(isRetryableSep10StoreError({ message: "duplicate key" })).toBe(false); + }); + + it("withSep10StoreRecovery retries then throws Sep10AuthError", async () => { + const fn = vi + .fn() + .mockRejectedValueOnce({ message: "503 temporarily unavailable" }) + .mockRejectedValueOnce({ message: "503 temporarily unavailable" }) + .mockRejectedValueOnce({ message: "503 temporarily unavailable" }); + + await expect(withSep10StoreRecovery(fn, "test")).rejects.toBeInstanceOf(Sep10AuthError); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it("lookupMerchantByStellarAddress returns merchant data on success", async () => { + const merchant = { id: "m-1", email: "a@example.com" }; + const supabaseClient = { + from: vi.fn().mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + is: vi.fn().mockReturnValue({ + maybeSingle: vi.fn().mockResolvedValue({ data: merchant, error: null }), + }), + }), + }), + }), + }; + + const result = await lookupMerchantByStellarAddress(clientKeypair.publicKey(), supabaseClient); + expect(result).toEqual(merchant); }); }); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 9560f8fe..0900322e 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -4,289 +4,227 @@ */ import express from "express"; -import rateLimit from "express-rate-limit"; +import * as StellarSdk from "stellar-sdk"; import { supabase } from "../lib/supabase.js"; import { generateChallenge, verifyChallenge, generateSessionToken, + getHomeDomain, + getNetworkPassphrase, + lookupMerchantByStellarAddress, + Sep10AuthError, + validateChallengeXdr, } from "../lib/sep10-auth.js"; import { hashPassword, verifyPassword } from "../lib/auth.js"; import { logLoginAttempt } from "../lib/audit.js"; import { validateRequest } from "../lib/validation.js"; import { authChallengeSchema, authVerifySchema } from "../lib/request-schemas.js"; - -const router = express.Router(); - -const sep10ChallengeRateLimit = rateLimit({ - windowMs: 60 * 1000, - max: 10, - message: { error: "Too many challenge requests, please try again later" }, - standardHeaders: true, - legacyHeaders: false, - validate: { ip: false }, - passOnStoreError: true, -}); - -const sep10VerifyRateLimit = rateLimit({ - windowMs: 60 * 1000, - max: 10, - message: { error: "Too many verification attempts, please try again later" }, - standardHeaders: true, - legacyHeaders: false, - validate: { ip: false }, - passOnStoreError: true, -}); - -/** - * @swagger - * /api/auth/login: - * post: - * summary: Login with email and password - * tags: [Auth] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [email, password] - * properties: - * email: - * type: string - * password: - * type: string - * responses: - * 200: - * description: Login successful - * 401: - * description: Invalid credentials - */ -router.post("/auth/login", async (req, res, next) => { - const ipAddress = req.ip ?? null; - const userAgent = req.get("user-agent") ?? null; - - try { - const { email, password } = req.body; - - if (!email || !password) { - return res.status(400).json({ error: "Email and password are required" }); - } - - const { data: merchant, error } = await supabase - .from("merchants") - .select("id, email, business_name, notification_email, password_hash, api_key, webhook_secret, merchant_settings") - .eq("email", email.toLowerCase().trim()) - .is("deleted_at", null) - .maybeSingle(); - - if (error) { error.status = 500; throw error; } - - if (!merchant || !merchant.password_hash) { - await logLoginAttempt({ merchantId: null, ipAddress, userAgent, status: "failure" }); - return res.status(401).json({ error: "Invalid email or password" }); - } - - const valid = await verifyPassword(password, merchant.password_hash); - if (!valid) { - await logLoginAttempt({ merchantId: merchant.id, ipAddress, userAgent, status: "failure" }); - return res.status(401).json({ error: "Invalid email or password" }); - } - - const token = generateSessionToken(merchant.id, merchant.email); - - await logLoginAttempt({ merchantId: merchant.id, ipAddress, userAgent, status: "success" }); - - res.json({ - token, - merchant: { - id: merchant.id, - email: merchant.email, - business_name: merchant.business_name, - notification_email: merchant.notification_email, - api_key: merchant.api_key, - webhook_secret: merchant.webhook_secret, - merchant_settings: merchant.merchant_settings, - }, - }); - } catch (err) { - next(err); - } -}); - -/* - * post: - * summary: Generate a SEP-0010 challenge transaction - * tags: [Auth] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [account] - * properties: - * account: - * type: string - * description: Stellar public key (G...) - * responses: - * 200: - * description: Challenge transaction - * content: - * application/json: - * schema: - * type: object - * properties: - * transaction: - * type: string - * description: Base64-encoded challenge XDR - * network_passphrase: - * type: string - * 400: - * description: Invalid request - */ -router.post("/auth/challenge", sep10ChallengeRateLimit, validateRequest({ body: authChallengeSchema }), async (req, res, next) => { - try { - const { account } = req.body; - - const challengeXdr = generateChallenge(account); - const networkPassphrase = - process.env.STELLAR_NETWORK === "public" - ? "Public Global Stellar Network ; September 2015" - : "Test SDF Network ; September 2015"; - - res.json({ - transaction: challengeXdr, - network_passphrase: networkPassphrase, - }); - } catch (err) { - next(err); - } -}); - -/** - * @swagger - * /api/auth/verify: - * post: - * summary: Verify a signed SEP-0010 challenge and issue session token - * tags: [Auth] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [transaction] - * properties: - * transaction: - * type: string - * description: Signed challenge transaction XDR - * responses: - * 200: - * description: Authentication successful - * content: - * application/json: - * schema: - * type: object - * properties: - * token: - * type: string - * description: Session JWT token - * merchant: - * type: object - * 401: - * description: Authentication failed - */ -router.post("/auth/verify", sep10VerifyRateLimit, validateRequest({ body: authVerifySchema }), async (req, res, next) => { - const ipAddress = req.ip ?? null; - const userAgent = req.get("user-agent") ?? null; - - try { - const { transaction } = req.body; - - // Extract client account from transaction - const StellarSdk = await import("stellar-sdk"); - const networkPassphrase = - process.env.STELLAR_NETWORK === "public" - ? StellarSdk.Networks.PUBLIC - : StellarSdk.Networks.TESTNET; - - const tx = StellarSdk.TransactionBuilder.fromXDR( - transaction, - networkPassphrase, - ); - - const operation = tx.operations?.[0]; - const clientAccount = operation?.source; - if (!clientAccount || typeof clientAccount !== "string") { - return res.status(400).json({ error: "Invalid transaction structure" }); - } - - // Verify challenge signature - const verification = verifyChallenge( - transaction, - clientAccount, - process.env.HOME_DOMAIN, - ); - - if (!verification.valid) { - await logLoginAttempt({ - merchantId: null, - ipAddress, - userAgent, - status: "failure", - }); - return res.status(401).json({ error: verification.error }); - } - - // Look up merchant by Stellar address - const { data: merchant, error } = await supabase - .from("merchants") - .select("id, email, business_name, notification_email") - .eq("recipient", clientAccount) - .is("deleted_at", null) - .maybeSingle(); - - if (error) { - error.status = 500; - throw error; - } - - if (!merchant) { - await logLoginAttempt({ - merchantId: null, - ipAddress, - userAgent, - status: "failure", - }); - return res.status(401).json({ - error: "No merchant account found for this Stellar address", +import { + createSep10ChallengeRateLimit, + createSep10VerifyRateLimit, +} from "../lib/rate-limit.js"; + +const defaultSep10ChallengeRateLimit = createSep10ChallengeRateLimit(); +const defaultSep10VerifyRateLimit = createSep10VerifyRateLimit(); + +export default function createAuthRouter({ + sep10ChallengeRateLimit = defaultSep10ChallengeRateLimit, + sep10VerifyRateLimit = defaultSep10VerifyRateLimit, +} = {}) { + const router = express.Router(); + + /** + * @swagger + * /api/auth/login: + * post: + * summary: Login with email and password + * tags: [Auth] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: + * type: string + * password: + * type: string + * responses: + * 200: + * description: Login successful + * 401: + * description: Invalid credentials + */ + router.post("/auth/login", async (req, res, next) => { + const ipAddress = req.ip ?? null; + const userAgent = req.get("user-agent") ?? null; + + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: "Email and password are required" }); + } + + const { data: merchant, error } = await supabase + .from("merchants") + .select("id, email, business_name, notification_email, password_hash, api_key, webhook_secret, merchant_settings") + .eq("email", email.toLowerCase().trim()) + .is("deleted_at", null) + .maybeSingle(); + + if (error) { + error.status = 500; + throw error; + } + + if (!merchant || !merchant.password_hash) { + await logLoginAttempt({ merchantId: null, ipAddress, userAgent, status: "failure" }); + return res.status(401).json({ error: "Invalid email or password" }); + } + + const valid = await verifyPassword(password, merchant.password_hash); + if (!valid) { + await logLoginAttempt({ merchantId: merchant.id, ipAddress, userAgent, status: "failure" }); + return res.status(401).json({ error: "Invalid email or password" }); + } + + const token = generateSessionToken(merchant.id, merchant.email); + + await logLoginAttempt({ merchantId: merchant.id, ipAddress, userAgent, status: "success" }); + + res.json({ + token, + merchant: { + id: merchant.id, + email: merchant.email, + business_name: merchant.business_name, + notification_email: merchant.notification_email, + api_key: merchant.api_key, + webhook_secret: merchant.webhook_secret, + merchant_settings: merchant.merchant_settings, + }, }); + } catch (err) { + next(err); } - - // Generate session token - const token = generateSessionToken(merchant.id, merchant.email || clientAccount); - - // Audit: successful login - await logLoginAttempt({ - merchantId: merchant.id, - ipAddress, - userAgent, - status: "success", - }); - - res.json({ - token, - merchant: { - id: merchant.id, - email: merchant.email, - business_name: merchant.business_name, - stellar_address: clientAccount, - }, - }); - } catch (err) { - next(err); - } -}); - -export default router; + }); + + router.post( + "/auth/challenge", + sep10ChallengeRateLimit, + validateRequest({ body: authChallengeSchema }), + async (req, res, next) => { + try { + const { account } = req.body; + + const challengeXdr = generateChallenge(account); + const networkPassphrase = + process.env.STELLAR_NETWORK === "public" + ? "Public Global Stellar Network ; September 2015" + : "Test SDF Network ; September 2015"; + + res.json({ + transaction: challengeXdr, + network_passphrase: networkPassphrase, + }); + } catch (err) { + next(err); + } + }, + ); + + router.post( + "/auth/verify", + sep10VerifyRateLimit, + validateRequest({ body: authVerifySchema }), + async (req, res, next) => { + const ipAddress = req.ip ?? null; + const userAgent = req.get("user-agent") ?? null; + + try { + const { transaction } = req.body; + + const xdrValidation = validateChallengeXdr(transaction); + if (!xdrValidation.valid) { + return res.status(400).json({ error: xdrValidation.error }); + } + + let tx; + try { + tx = StellarSdk.TransactionBuilder.fromXDR(transaction, getNetworkPassphrase()); + } catch { + return res.status(400).json({ error: "Invalid challenge transaction" }); + } + + const operation = tx.operations?.[0]; + const clientAccount = operation?.source; + if (!clientAccount || typeof clientAccount !== "string") { + return res.status(400).json({ error: "Invalid transaction structure" }); + } + + const verification = verifyChallenge(transaction, clientAccount, getHomeDomain()); + + if (!verification.valid) { + await logLoginAttempt({ + merchantId: null, + ipAddress, + userAgent, + status: "failure", + }); + return res.status(401).json({ + error: verification.error, + code: verification.code, + }); + } + + const merchant = await lookupMerchantByStellarAddress(clientAccount, supabase); + + if (!merchant) { + await logLoginAttempt({ + merchantId: null, + ipAddress, + userAgent, + status: "failure", + }); + return res.status(401).json({ + error: "No merchant account found for this Stellar address", + }); + } + + const token = generateSessionToken(merchant.id, merchant.email || clientAccount); + + await logLoginAttempt({ + merchantId: merchant.id, + ipAddress, + userAgent, + status: "success", + }); + + res.json({ + token, + merchant: { + id: merchant.id, + email: merchant.email, + business_name: merchant.business_name, + stellar_address: clientAccount, + }, + }); + } catch (err) { + if (err instanceof Sep10AuthError) { + return res.status(err.httpStatus).json({ + error: err.code, + message: err.message, + retryable: err.retryable, + }); + } + next(err); + } + }, + ); + + return router; +} diff --git a/backend/src/routes/auth.routes.test.js b/backend/src/routes/auth.routes.test.js new file mode 100644 index 00000000..f7ebf474 --- /dev/null +++ b/backend/src/routes/auth.routes.test.js @@ -0,0 +1,153 @@ +import express from "express"; +import request from "supertest"; +import * as StellarSdk from "stellar-sdk"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockMaybeSingle, mockFrom, mockLogLoginAttempt } = vi.hoisted(() => ({ + mockMaybeSingle: vi.fn(), + mockFrom: vi.fn(), + mockLogLoginAttempt: vi.fn(), +})); + +vi.mock("../lib/supabase.js", () => ({ + supabase: { + from: mockFrom, + }, +})); + +vi.mock("../lib/audit.js", () => ({ + logLoginAttempt: mockLogLoginAttempt, +})); + +vi.mock("../lib/auth.js", () => ({ + hashPassword: vi.fn(), + verifyPassword: vi.fn(), +})); + +vi.mock("../lib/validation.js", () => ({ + validateRequest: () => (_req, _res, next) => next(), +})); + +vi.mock("../lib/request-schemas.js", () => ({ + authChallengeSchema: {}, + authVerifySchema: {}, +})); + +import createAuthRouter from "./auth.js"; +import { + createSep10ChallengeRateLimit, + createSep10VerifyRateLimit, + getSep10ChallengeRateLimitKey, + getSep10VerifyRateLimitKey, +} from "../lib/rate-limit.js"; +import { _resetNonceCacheForTests } from "../lib/sep10-auth.js"; + +function createApp(router) { + const app = express(); + app.use(express.json()); + app.use("/api", router); + return app; +} + +describe("SEP-10 auth routes", () => { + let clientKeypair; + let serverKeypair; + + beforeAll(() => { + process.env.JWT_SECRET = "test-jwt-secret"; + process.env.HOME_DOMAIN = "localhost"; + clientKeypair = StellarSdk.Keypair.random(); + serverKeypair = StellarSdk.Keypair.random(); + process.env.SEP10_SERVER_SIGNING_KEY = serverKeypair.secret(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + _resetNonceCacheForTests(); + mockFrom.mockReturnValue({ + select: vi.fn().mockReturnValue({ + eq: vi.fn().mockReturnValue({ + is: vi.fn().mockReturnValue({ + maybeSingle: mockMaybeSingle, + }), + }), + }), + }); + }); + + it("builds challenge rate-limit keys from account and IP", () => { + const key = getSep10ChallengeRateLimitKey({ + body: { account: "GABC123" }, + ip: "203.0.113.10", + }); + expect(key).toContain("sep10:challenge:GABC123:"); + }); + + it("builds verify rate-limit keys from client IP", () => { + const key = getSep10VerifyRateLimitKey({ ip: "203.0.113.10" }); + expect(key).toBe("sep10:verify:203.0.113.10"); + }); + + it("rate-limits repeated challenge requests for the same account", async () => { + const limiter = createSep10ChallengeRateLimit({ max: 1, windowMs: 60_000 }); + const app = createApp(createAuthRouter({ sep10ChallengeRateLimit: limiter })); + + await request(app) + .post("/api/auth/challenge") + .send({ account: clientKeypair.publicKey() }) + .expect(200); + + const limited = await request(app) + .post("/api/auth/challenge") + .send({ account: clientKeypair.publicKey() }); + + expect(limited.status).toBe(429); + expect(limited.body.code).toBe("SEP10_RATE_LIMITED"); + }); + + it("returns retryable 503 when merchant lookup store is temporarily unavailable", async () => { + const challengeRes = await request( + createApp(createAuthRouter({ sep10VerifyRateLimit: createSep10VerifyRateLimit({ max: 100 }) })), + ) + .post("/api/auth/challenge") + .send({ account: clientKeypair.publicKey() }) + .expect(200); + + const tx = StellarSdk.TransactionBuilder.fromXDR( + challengeRes.body.transaction, + StellarSdk.Networks.TESTNET, + ); + tx.sign(clientKeypair); + + mockMaybeSingle.mockResolvedValue({ + data: null, + error: { message: "fetch failed: upstream timeout" }, + }); + + const response = await request( + createApp(createAuthRouter({ sep10VerifyRateLimit: createSep10VerifyRateLimit({ max: 100 }) })), + ) + .post("/api/auth/verify") + .send({ transaction: tx.toXDR() }); + + expect(response.status).toBe(503); + expect(response.body).toEqual({ + error: "SERVICE_UNAVAILABLE", + message: "Authentication store temporarily unavailable, please retry", + retryable: true, + }); + }); + + it("rate-limits repeated verify attempts from the same IP", async () => { + const verifyLimiter = createSep10VerifyRateLimit({ max: 1, windowMs: 60_000 }); + const app = createApp(createAuthRouter({ sep10VerifyRateLimit: verifyLimiter })); + + const invalidTx = { transaction: "not-valid-base64!!!" }; + const first = await request(app).post("/api/auth/verify").send(invalidTx); + const second = await request(app).post("/api/auth/verify").send(invalidTx); + + expect(first.status).toBe(400); + expect(second.status).toBe(429); + expect(second.body.code).toBe("SEP10_RATE_LIMITED"); + }); +}); diff --git a/backend/src/routes/sep12.test.js b/backend/src/routes/sep12.test.js index c25ba7fb..a0643026 100644 --- a/backend/src/routes/sep12.test.js +++ b/backend/src/routes/sep12.test.js @@ -42,6 +42,7 @@ vi.mock("../lib/logger.js", () => ({ import createSep12Router, { buildSep12RateLimitKey, createSep12RateLimit, + createSep12WriteRateLimit, } from "./sep12.js"; function createApp(router) { @@ -111,4 +112,35 @@ describe("SEP-12 routes", () => { message: "Too many KYC requests, please try again later", }); }); + + it("rate-limits repeated KYC write requests per account", async () => { + mockPutCustomer.mockResolvedValue({ id: "kyc-1", status: "pending" }); + const limiter = createSep12WriteRateLimit({ max: 1, windowMs: 60_000 }); + const app = createApp( + (() => { + const router = express.Router(); + router.use(limiter); + router.put("/sep12/customer", async (req, res) => { + const result = await mockPutCustomer(req.body); + res.status(202).json(result); + }); + return router; + })(), + ); + + await request(app) + .put("/sep12/customer") + .send({ account: "G-ONE", fields: { first_name: "Ada" } }) + .expect(202); + + const limited = await request(app) + .put("/sep12/customer") + .send({ account: "G-ONE", fields: { first_name: "Grace" } }); + + expect(limited.status).toBe(429); + expect(limited.body).toEqual({ + error: "TOO_MANY_REQUESTS", + message: "Too many KYC write requests, please try again later", + }); + }); });