Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
269 changes: 203 additions & 66 deletions backend/dist/routes/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
21 changes: 21 additions & 0 deletions backend/migrations/20260529000001_session_expiry_cleanup.sql
Original file line number Diff line number Diff line change
@@ -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;
$$;
4 changes: 4 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
"dev": "nodemon src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
{
"scripts": {
"test": "node --require ts-node/register --test tests/**/*.test.ts"
}
}
},
"keywords": [],
"author": "",
Expand Down
4 changes: 4 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,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 {
Expand Down Expand Up @@ -282,6 +284,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 {
Expand Down
25 changes: 25 additions & 0 deletions backend/scripts/auth-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Loading
Loading