From 8bb946520b89c8eda98cf750c45d0e029266f25d Mon Sep 17 00:00:00 2001 From: sochima2 Date: Fri, 29 May 2026 16:31:03 +0100 Subject: [PATCH] Implement auth session expiry cleanup --- backend/dist/routes/auth.js | 269 +++++++++++---- .../20260529000001_session_expiry_cleanup.sql | 21 ++ backend/package.json | 2 +- backend/prisma/schema.prisma | 4 + backend/scripts/auth-helpers.test.ts | 25 ++ backend/src/routes/auth.ts | 319 +++++++++++++----- 6 files changed, 496 insertions(+), 144 deletions(-) create mode 100644 backend/migrations/20260529000001_session_expiry_cleanup.sql create mode 100644 backend/scripts/auth-helpers.test.ts diff --git a/backend/dist/routes/auth.js b/backend/dist/routes/auth.js index b0eda709..06c9a37b 100644 --- a/backend/dist/routes/auth.js +++ b/backend/dist/routes/auth.js @@ -3,30 +3,168 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); +exports.normalizeStellarAddress = normalizeStellarAddress; +exports.decodeSignature = decodeSignature; +exports.verifyStellarSignature = verifyStellarSignature; +exports.isChallengeExpired = isChallengeExpired; const express_1 = require("express"); const crypto_1 = __importDefault(require("crypto")); const db_1 = require("../config/db"); const stellar_sdk_1 = require("@stellar/stellar-sdk"); +const ioredis_1 = __importDefault(require("ioredis")); const router = (0, express_1.Router)(); +const CHALLENGE_TTL_MS = 5 * 60 * 1000; +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const STELLAR_SIGNED_MESSAGE_PREFIX = "Stellar Signed Message:\n"; +const REDIS_BLACKLIST_LOOKUP_BUDGET_MS = 1; +const SESSION_COOKIE_NAME = "lance_session"; +const BLACKLIST_KEY_PREFIX = "auth:blacklist:session:"; +let redisClient; +function getRedisClient() { + if (redisClient !== undefined) { + return redisClient; + } + const redisUrl = process.env.REDIS_URL; + if (!redisUrl) { + redisClient = null; + return redisClient; + } + redisClient = new ioredis_1.default(redisUrl, { + enableOfflineQueue: false, + lazyConnect: false, + maxRetriesPerRequest: 0, + }); + redisClient.on("error", (error) => { + console.error("Redis auth blacklist client error:", error); + }); + return redisClient; +} +function sha256Hex(value) { + return crypto_1.default.createHash("sha256").update(value).digest("hex"); +} +function blacklistKeyForToken(token) { + return `${BLACKLIST_KEY_PREFIX}${sha256Hex(token)}`; +} +async function isSessionBlacklisted(token) { + const client = getRedisClient(); + if (!client) { + return false; + } + const lookup = client + .get(blacklistKeyForToken(token)) + .then((value) => value !== null) + .catch(() => false); + const timeout = new Promise((resolve) => { + setTimeout(() => resolve(false), REDIS_BLACKLIST_LOOKUP_BUDGET_MS).unref(); + }); + return Promise.race([lookup, timeout]); +} +function normalizeStellarAddress(address) { + if (typeof address !== "string") { + return null; + } + const normalized = address.trim().toUpperCase(); + if (!stellar_sdk_1.StrKey.isValidEd25519PublicKey(normalized)) { + return null; + } + try { + // StrKey decoding validates the version byte and CRC16-XModem checksum. Keeping the + // decoded byte-length assertion here makes future decoder substitutions auditable. + const decoded = stellar_sdk_1.StrKey.decodeEd25519PublicKey(normalized); + if (decoded.length !== 32) { + return null; + } + stellar_sdk_1.Keypair.fromPublicKey(normalized); + return normalized; + } + catch { + return null; + } +} +function extractSignatureString(signature) { + if (typeof signature === "string") { + return signature.trim(); + } + if (signature && typeof signature === "object") { + const wrapped = signature; + const candidate = wrapped.signature ?? wrapped.signedMessage; + if (typeof candidate === "string") { + return candidate.trim(); + } + } + return null; +} +function decodeSignature(signature) { + const sigString = extractSignatureString(signature); + if (!sigString) { + return null; + } + const candidates = []; + if (/^[0-9a-fA-F]+$/.test(sigString) && sigString.length % 2 === 0) { + candidates.push(Buffer.from(sigString, "hex")); + } + if (/^[A-Za-z0-9+/]+={0,2}$/.test(sigString)) { + candidates.push(Buffer.from(sigString, "base64")); + } + if (/^[A-Za-z0-9_-]+={0,2}$/.test(sigString)) { + candidates.push(Buffer.from(sigString.replace(/-/g, "+").replace(/_/g, "/"), "base64")); + } + return candidates.find((candidate) => candidate.length === 64) ?? null; +} +function sep53MessageHash(challenge) { + return crypto_1.default.createHash("sha256").update(Buffer.from(STELLAR_SIGNED_MESSAGE_PREFIX + challenge)).digest(); +} +function verifyStellarSignature(address, challenge, signature) { + const normalizedAddress = normalizeStellarAddress(address); + const signatureBuffer = decodeSignature(signature); + if (!normalizedAddress || !signatureBuffer) { + return false; + } + const keypair = stellar_sdk_1.Keypair.fromPublicKey(normalizedAddress); + return keypair.verify(sep53MessageHash(challenge), signatureBuffer); +} +function isChallengeExpired(expiresAt, now = new Date()) { + return expiresAt.getTime() <= now.getTime(); +} +function buildChallenge(address, nonce) { + return `Lance wants you to sign in with your Stellar account:\n${address}\n\nNonce: ${nonce}`; +} +function extractBearerToken(req) { + const authorization = req.header("authorization"); + if (authorization?.startsWith("Bearer ")) { + return authorization.slice("Bearer ".length).trim(); + } + const cookieHeader = req.header("cookie"); + if (!cookieHeader) { + return null; + } + const cookies = cookieHeader.split(";").map((cookie) => cookie.trim()); + const sessionCookie = cookies.find((cookie) => cookie.startsWith(`${SESSION_COOKIE_NAME}=`)); + return sessionCookie ? decodeURIComponent(sessionCookie.split("=").slice(1).join("=")) : null; +} +async function cleanupExpiredSessions(now) { + await db_1.prisma.sessions.deleteMany({ where: { expires_at: { lte: now } } }); +} // Scaffold the auth challenge route router.post("/challenge", async (req, res) => { try { - const { address } = req.body; + const address = normalizeStellarAddress(req.body.address); if (!address) { - return res.status(400).json({ error: "Address is required" }); + return res.status(400).json({ error: "A valid Stellar public address is required" }); } - // Generate challenge matching the old Rust backend format const nonce = crypto_1.default.randomUUID(); - const challenge = `Lance wants you to sign in with your Stellar account:\n${address}\n\nNonce: ${nonce}`; - // Expiration time: 5 minutes from now - const expiresAt = new Date(Date.now() + 5 * 60 * 1000); - // Save or update the challenge in the database - await db_1.prisma.auth_challenges.upsert({ - where: { address }, - update: { challenge, expires_at: expiresAt }, - create: { address, challenge, expires_at: expiresAt }, - }); - res.json({ challenge }); + const challenge = buildChallenge(address, nonce); + const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS); + await db_1.prisma.$transaction(async (tx) => { + // Keep the challenge table small and preserve point-lookups on the primary key. + await tx.auth_challenges.deleteMany({ where: { expires_at: { lte: new Date() } } }); + await tx.auth_challenges.upsert({ + where: { address }, + update: { challenge, expires_at: expiresAt }, + create: { address, challenge, expires_at: expiresAt }, + }); + }, { isolationLevel: "ReadCommitted" }); + res.json({ challenge, expires_at: expiresAt.toISOString() }); } catch (error) { console.error("Auth challenge error:", error); @@ -36,73 +174,72 @@ router.post("/challenge", async (req, res) => { // Verify route router.post("/verify", async (req, res) => { try { - const { address, signature } = req.body; + const address = normalizeStellarAddress(req.body.address); + const { signature } = req.body; if (!address || !signature) { - return res.status(400).json({ error: "Address and signature are required" }); + return res.status(400).json({ error: "Valid address and signature are required" }); } - // 1. Fetch the challenge + const now = new Date(); const record = await db_1.prisma.auth_challenges.findUnique({ where: { address } }); - if (!record) { - return res.status(404).json({ error: "Challenge not found. Please request a new challenge." }); - } - if (record.expires_at < new Date()) { - return res.status(400).json({ error: "Challenge expired" }); - } - // 2. Verify the signature - let isValid = false; - try { - const keypair = stellar_sdk_1.Keypair.fromPublicKey(address); - // Handle the case where signature is an object (some wallet kits wrap it) - const sigString = typeof signature === "object" && signature.signature - ? signature.signature - : typeof signature === "string" ? signature : ""; - const hexRegex = /^[0-9a-fA-F]+$/; - const signatureBuffer = hexRegex.test(sigString) && sigString.length % 2 === 0 - ? Buffer.from(sigString, "hex") - : Buffer.from(sigString, "base64"); - // Freighter (and stellar-wallets-kit) prefixes messages before hashing and signing to prevent spoofing - const SIGN_MESSAGE_PREFIX = "Stellar Signed Message:\n"; - const payloadBuffer = Buffer.from(SIGN_MESSAGE_PREFIX + record.challenge); - const messageHash = crypto_1.default.createHash("sha256").update(payloadBuffer).digest(); - isValid = keypair.verify(messageHash, signatureBuffer); - // Fallback for mock wallet in E2E tests (it returns the literal string "mock-signature") - if (!isValid && process.env.NODE_ENV !== "production") { - if (signature === record.challenge || signature === "mock-signature") { - isValid = true; - } - } - } - catch (err) { - console.error("Signature verification failed structurally:", err); - isValid = false; - } - // For local dev/E2E tests - if (!isValid && process.env.NODE_ENV !== "production") { - if (signature === record.challenge || signature === "mock-signature") { - isValid = true; + if (!record || isChallengeExpired(record.expires_at, now)) { + if (record) { + await db_1.prisma.auth_challenges.delete({ where: { address } }).catch(() => undefined); } + return res.status(401).json({ error: "Invalid or expired challenge" }); } + const isValid = verifyStellarSignature(address, record.challenge, signature); if (!isValid) { return res.status(401).json({ error: "Invalid signature" }); } - // 3. Delete the used challenge - await db_1.prisma.auth_challenges.delete({ where: { address } }); - // 4. Generate a session token const token = crypto_1.default.randomUUID(); - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days - // 5. Save the session - await db_1.prisma.sessions.create({ - data: { - token, - address, - expires_at: expiresAt, - }, + const expiresAt = new Date(now.getTime() + SESSION_TTL_MS); + await db_1.prisma.$transaction(async (tx) => { + await tx.auth_challenges.delete({ where: { address } }); + await tx.sessions.deleteMany({ where: { expires_at: { lte: now } } }); + await tx.sessions.create({ + data: { + token, + address, + expires_at: expiresAt, + }, + }); + }, { isolationLevel: "ReadCommitted" }); + res.cookie(SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + expires: expiresAt, + path: "/", }); - res.json({ token, address }); + res.json({ token, address, expires_at: expiresAt.toISOString() }); } catch (error) { console.error("Auth verify error:", error); res.status(500).json({ error: "Internal server error" }); } }); +router.get("/session", async (req, res) => { + try { + const token = extractBearerToken(req); + if (!token) { + return res.status(401).json({ error: "Session token is required" }); + } + if (await isSessionBlacklisted(token)) { + return res.status(401).json({ error: "Session has been revoked" }); + } + const now = new Date(); + const session = await db_1.prisma.sessions.findUnique({ where: { token } }); + if (!session || session.expires_at <= now) { + if (session) { + await cleanupExpiredSessions(now); + } + return res.status(401).json({ error: "Session expired or not found" }); + } + res.json({ address: session.address, expires_at: session.expires_at.toISOString() }); + } + catch (error) { + console.error("Auth session error:", error); + res.status(500).json({ error: "Internal server error" }); + } +}); exports.default = router; diff --git a/backend/migrations/20260529000001_session_expiry_cleanup.sql b/backend/migrations/20260529000001_session_expiry_cleanup.sql new file mode 100644 index 00000000..ecb13a76 --- /dev/null +++ b/backend/migrations/20260529000001_session_expiry_cleanup.sql @@ -0,0 +1,21 @@ +-- Add query-plan support and a reusable cleanup primitive for PostgreSQL-backed auth sessions. +-- The B-tree indexes keep expiry cleanup and active-session checks on indexed ranges instead +-- of table scans when the sessions table is under high-concurrency login traffic. +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_sessions_address_expires_at ON sessions(address, expires_at DESC); +CREATE INDEX IF NOT EXISTS idx_auth_challenges_expires_at ON auth_challenges(expires_at); + +CREATE OR REPLACE FUNCTION cleanup_expired_sessions(cutoff TIMESTAMPTZ DEFAULT now()) +RETURNS BIGINT +LANGUAGE plpgsql +AS $$ +DECLARE + deleted_count BIGINT; +BEGIN + DELETE FROM sessions + WHERE expires_at <= cutoff; + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; +END; +$$; diff --git a/backend/package.json b/backend/package.json index 9e3c1746..14133a39 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,7 @@ "dev": "nodemon src/index.ts", "build": "tsc", "start": "node dist/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "ts-node scripts/auth-helpers.test.ts" }, "keywords": [], "author": "", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index cee16860..2d3f3261 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -64,6 +64,8 @@ model auth_challenges { address String @id challenge String expires_at DateTime @db.Timestamptz(6) + + @@index([expires_at], map: "idx_auth_challenges_expires_at") } model bid_status_transitions { @@ -266,6 +268,8 @@ model sessions { expires_at DateTime @db.Timestamptz(6) @@index([address], map: "idx_sessions_address") + @@index([expires_at], map: "idx_sessions_expires_at") + @@index([address, expires_at(sort: Desc)], map: "idx_sessions_address_expires_at") } model transaction_metadata_cache { diff --git a/backend/scripts/auth-helpers.test.ts b/backend/scripts/auth-helpers.test.ts new file mode 100644 index 00000000..77a37415 --- /dev/null +++ b/backend/scripts/auth-helpers.test.ts @@ -0,0 +1,25 @@ +import assert from "assert"; +import crypto from "crypto"; +import { Keypair } from "@stellar/stellar-sdk"; +import { + decodeSignature, + isChallengeExpired, + normalizeStellarAddress, + verifyStellarSignature, +} from "../src/routes/auth"; + +const keypair = Keypair.random(); +const address = keypair.publicKey(); +const challenge = `Lance wants you to sign in with your Stellar account:\n${address}\n\nNonce: test-nonce`; +const messageHash = crypto.createHash("sha256").update(Buffer.from(`Stellar Signed Message:\n${challenge}`)).digest(); +const signature = keypair.sign(messageHash).toString("base64"); + +assert.strictEqual(normalizeStellarAddress(address), address, "valid Stellar G-address should normalize"); +assert.strictEqual(normalizeStellarAddress(`${address.slice(0, -1)}A`), null, "bad checksum should be rejected"); +assert.strictEqual(decodeSignature(signature)?.length, 64, "SEP-53 signatures are 64 raw Ed25519 bytes"); +assert.strictEqual(verifyStellarSignature(address, challenge, signature), true, "valid SEP-53 signature should verify"); +assert.strictEqual(verifyStellarSignature(address, `${challenge}!`, signature), false, "tampered challenge should fail"); +assert.strictEqual(isChallengeExpired(new Date(Date.now() - 1_000)), true, "past challenge should be expired"); +assert.strictEqual(isChallengeExpired(new Date(Date.now() + 60_000)), false, "future challenge should remain active"); + +console.log("auth helper mockups passed"); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 10186baa..b8a4b830 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,10 +1,18 @@ import { Router, Request, Response } from "express"; import crypto from "crypto"; import { prisma } from "../config/db"; -import { Keypair } from "@stellar/stellar-sdk"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import Redis from "ioredis"; const router = Router(); +const CHALLENGE_TTL_MS = 5 * 60 * 1000; +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const STELLAR_SIGNED_MESSAGE_PREFIX = "Stellar Signed Message:\n"; +const REDIS_BLACKLIST_LOOKUP_BUDGET_MS = 1; +const SESSION_COOKIE_NAME = "lance_session"; +const BLACKLIST_KEY_PREFIX = "auth:blacklist:session:"; + // Define input types interface ChallengeRequest { address: string; @@ -12,33 +20,192 @@ interface ChallengeRequest { interface VerifyRequest { address: string; - signature: string; + signature: unknown; +} + +let redisClient: Redis | null | undefined; + +function getRedisClient(): Redis | null { + if (redisClient !== undefined) { + return redisClient; + } + + const redisUrl = process.env.REDIS_URL; + if (!redisUrl) { + redisClient = null; + return redisClient; + } + + redisClient = new Redis(redisUrl, { + enableOfflineQueue: false, + lazyConnect: false, + maxRetriesPerRequest: 0, + }); + + redisClient.on("error", (error) => { + console.error("Redis auth blacklist client error:", error); + }); + + return redisClient; +} + +function sha256Hex(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} + +function blacklistKeyForToken(token: string): string { + return `${BLACKLIST_KEY_PREFIX}${sha256Hex(token)}`; +} + +async function isSessionBlacklisted(token: string): Promise { + const client = getRedisClient(); + if (!client) { + return false; + } + + const lookup = client + .get(blacklistKeyForToken(token)) + .then((value) => value !== null) + .catch(() => false); + const timeout = new Promise((resolve) => { + setTimeout(() => resolve(false), REDIS_BLACKLIST_LOOKUP_BUDGET_MS).unref(); + }); + + return Promise.race([lookup, timeout]); +} + +export function normalizeStellarAddress(address: unknown): string | null { + if (typeof address !== "string") { + return null; + } + + const normalized = address.trim().toUpperCase(); + if (!StrKey.isValidEd25519PublicKey(normalized)) { + return null; + } + + try { + // StrKey decoding validates the version byte and CRC16-XModem checksum. Keeping the + // decoded byte-length assertion here makes future decoder substitutions auditable. + const decoded = StrKey.decodeEd25519PublicKey(normalized); + if (decoded.length !== 32) { + return null; + } + Keypair.fromPublicKey(normalized); + return normalized; + } catch { + return null; + } +} + +function extractSignatureString(signature: unknown): string | null { + if (typeof signature === "string") { + return signature.trim(); + } + + if (signature && typeof signature === "object") { + const wrapped = signature as Record; + const candidate = wrapped.signature ?? wrapped.signedMessage; + if (typeof candidate === "string") { + return candidate.trim(); + } + } + + return null; +} + +export function decodeSignature(signature: unknown): Buffer | null { + const sigString = extractSignatureString(signature); + if (!sigString) { + return null; + } + + const candidates: Buffer[] = []; + if (/^[0-9a-fA-F]+$/.test(sigString) && sigString.length % 2 === 0) { + candidates.push(Buffer.from(sigString, "hex")); + } + + if (/^[A-Za-z0-9+/]+={0,2}$/.test(sigString)) { + candidates.push(Buffer.from(sigString, "base64")); + } + + if (/^[A-Za-z0-9_-]+={0,2}$/.test(sigString)) { + candidates.push(Buffer.from(sigString.replace(/-/g, "+").replace(/_/g, "/"), "base64")); + } + + return candidates.find((candidate) => candidate.length === 64) ?? null; +} + +function sep53MessageHash(challenge: string): Buffer { + return crypto.createHash("sha256").update(Buffer.from(STELLAR_SIGNED_MESSAGE_PREFIX + challenge)).digest(); +} + +export function verifyStellarSignature(address: string, challenge: string, signature: unknown): boolean { + const normalizedAddress = normalizeStellarAddress(address); + const signatureBuffer = decodeSignature(signature); + if (!normalizedAddress || !signatureBuffer) { + return false; + } + + const keypair = Keypair.fromPublicKey(normalizedAddress); + return keypair.verify(sep53MessageHash(challenge), signatureBuffer); +} + +export function isChallengeExpired(expiresAt: Date, now = new Date()): boolean { + return expiresAt.getTime() <= now.getTime(); +} + +function buildChallenge(address: string, nonce: string): string { + return `Lance wants you to sign in with your Stellar account:\n${address}\n\nNonce: ${nonce}`; +} + +function extractBearerToken(req: Request): string | null { + const authorization = req.header("authorization"); + if (authorization?.startsWith("Bearer ")) { + return authorization.slice("Bearer ".length).trim(); + } + + const cookieHeader = req.header("cookie"); + if (!cookieHeader) { + return null; + } + + const cookies = cookieHeader.split(";").map((cookie) => cookie.trim()); + const sessionCookie = cookies.find((cookie) => cookie.startsWith(`${SESSION_COOKIE_NAME}=`)); + return sessionCookie ? decodeURIComponent(sessionCookie.split("=").slice(1).join("=")) : null; +} + +async function cleanupExpiredSessions(now: Date): Promise { + await prisma.sessions.deleteMany({ where: { expires_at: { lte: now } } }); } // Scaffold the auth challenge route router.post("/challenge", async (req: Request<{}, {}, ChallengeRequest>, res: Response) => { try { - const { address } = req.body; + const address = normalizeStellarAddress(req.body.address); if (!address) { - return res.status(400).json({ error: "Address is required" }); + return res.status(400).json({ error: "A valid Stellar public address is required" }); } - // Generate challenge matching the old Rust backend format const nonce = crypto.randomUUID(); - const challenge = `Lance wants you to sign in with your Stellar account:\n${address}\n\nNonce: ${nonce}`; - - // Expiration time: 5 minutes from now - const expiresAt = new Date(Date.now() + 5 * 60 * 1000); + const challenge = buildChallenge(address, nonce); + const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS); - // Save or update the challenge in the database - await prisma.auth_challenges.upsert({ - where: { address }, - update: { challenge, expires_at: expiresAt }, - create: { address, challenge, expires_at: expiresAt }, - }); + await prisma.$transaction( + async (tx: any) => { + // Keep the challenge table small and preserve point-lookups on the primary key. + await tx.auth_challenges.deleteMany({ where: { expires_at: { lte: new Date() } } }); + await tx.auth_challenges.upsert({ + where: { address }, + update: { challenge, expires_at: expiresAt }, + create: { address, challenge, expires_at: expiresAt }, + }); + }, + { isolationLevel: "ReadCommitted" }, + ); - res.json({ challenge }); + res.json({ challenge, expires_at: expiresAt.toISOString() }); } catch (error) { console.error("Auth challenge error:", error); res.status(500).json({ error: "Internal server error" }); @@ -48,87 +215,85 @@ router.post("/challenge", async (req: Request<{}, {}, ChallengeRequest>, res: Re // Verify route router.post("/verify", async (req: Request<{}, {}, VerifyRequest>, res: Response) => { try { - const { address, signature } = req.body; + const address = normalizeStellarAddress(req.body.address); + const { signature } = req.body; if (!address || !signature) { - return res.status(400).json({ error: "Address and signature are required" }); + return res.status(400).json({ error: "Valid address and signature are required" }); } - // 1. Fetch the challenge + const now = new Date(); const record = await prisma.auth_challenges.findUnique({ where: { address } }); - if (!record) { - return res.status(404).json({ error: "Challenge not found. Please request a new challenge." }); - } - - if (record.expires_at < new Date()) { - return res.status(400).json({ error: "Challenge expired" }); - } - - // 2. Verify the signature - let isValid = false; - try { - const keypair = Keypair.fromPublicKey(address); - - // Handle the case where signature is an object (some wallet kits wrap it) - const sigString = typeof signature === "object" && (signature as any).signature - ? (signature as any).signature - : typeof signature === "string" ? signature : ""; - - const hexRegex = /^[0-9a-fA-F]+$/; - const signatureBuffer = hexRegex.test(sigString) && sigString.length % 2 === 0 - ? Buffer.from(sigString, "hex") - : Buffer.from(sigString, "base64"); - - // Freighter (and stellar-wallets-kit) prefixes messages before hashing and signing to prevent spoofing - const SIGN_MESSAGE_PREFIX = "Stellar Signed Message:\n"; - const payloadBuffer = Buffer.from(SIGN_MESSAGE_PREFIX + record.challenge); - const messageHash = crypto.createHash("sha256").update(payloadBuffer).digest(); - - isValid = keypair.verify(messageHash, signatureBuffer); - - // Fallback for mock wallet in E2E tests (it returns the literal string "mock-signature") - if (!isValid && process.env.NODE_ENV !== "production") { - if (signature === record.challenge || signature === "mock-signature") { - isValid = true; - } - } - } catch (err) { - console.error("Signature verification failed structurally:", err); - isValid = false; - } - - // For local dev/E2E tests - if (!isValid && process.env.NODE_ENV !== "production") { - if (signature === record.challenge || signature === "mock-signature") { - isValid = true; + if (!record || isChallengeExpired(record.expires_at, now)) { + if (record) { + await prisma.auth_challenges.delete({ where: { address } }).catch(() => undefined); } + return res.status(401).json({ error: "Invalid or expired challenge" }); } + const isValid = verifyStellarSignature(address, record.challenge, signature); if (!isValid) { return res.status(401).json({ error: "Invalid signature" }); } - // 3. Delete the used challenge - await prisma.auth_challenges.delete({ where: { address } }); - - // 4. Generate a session token const token = crypto.randomUUID(); - const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days - - // 5. Save the session - await prisma.sessions.create({ - data: { - token, - address, - expires_at: expiresAt, + const expiresAt = new Date(now.getTime() + SESSION_TTL_MS); + + await prisma.$transaction( + async (tx: any) => { + await tx.auth_challenges.delete({ where: { address } }); + await tx.sessions.deleteMany({ where: { expires_at: { lte: now } } }); + await tx.sessions.create({ + data: { + token, + address, + expires_at: expiresAt, + }, + }); }, + { isolationLevel: "ReadCommitted" }, + ); + + res.cookie(SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + expires: expiresAt, + path: "/", }); - res.json({ token, address }); + res.json({ token, address, expires_at: expiresAt.toISOString() }); } catch (error) { console.error("Auth verify error:", error); res.status(500).json({ error: "Internal server error" }); } }); +router.get("/session", async (req: Request, res: Response) => { + try { + const token = extractBearerToken(req); + if (!token) { + return res.status(401).json({ error: "Session token is required" }); + } + + if (await isSessionBlacklisted(token)) { + return res.status(401).json({ error: "Session has been revoked" }); + } + + const now = new Date(); + const session = await prisma.sessions.findUnique({ where: { token } }); + if (!session || session.expires_at <= now) { + if (session) { + await cleanupExpiredSessions(now); + } + return res.status(401).json({ error: "Session expired or not found" }); + } + + res.json({ address: session.address, expires_at: session.expires_at.toISOString() }); + } catch (error) { + console.error("Auth session error:", error); + res.status(500).json({ error: "Internal server error" }); + } +}); + export default router;