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 08c88d45..9ef65588 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": "", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ccc46995..18440018 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 { @@ -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 { 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 f512c2e8..2c2538e2 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -7,9 +7,9 @@ import crypto from "crypto"; import jwt, { SignOptions, JwtPayload } from "jsonwebtoken"; import { z } from "zod"; import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import Redis from "ioredis"; import { prisma } from "../config/db"; -import { redis } from "../config/redis"; const router = Router(); @@ -21,91 +21,193 @@ const CHALLENGE_TTL_MS = 5 * 60 * 1000; const ACCESS_TOKEN_TTL_SEC = 15 * 60; const REFRESH_TOKEN_TTL_SEC = 7 * 24 * 60 * 60; +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; const STELLAR_SIGN_PREFIX = "Stellar Signed Message:\n"; const BLACKLIST_NS = "jwt:blacklist:"; +const SESSION_BLACKLIST_NS = "auth:blacklist:session:"; const ACCESS_TOKEN_COOKIE = "lance_access_token"; const REFRESH_TOKEN_COOKIE = "lance_refresh_token"; +const SESSION_COOKIE_NAME = "lance_session"; -/** Tight budget for Redis blacklist lookups — fail open on latency spikes. */ const BLACKLIST_TIMEOUT_MS = 5; const isProduction = process.env.NODE_ENV === "production"; const COOKIE_BASE_OPTIONS = { - httpOnly: true, - secure: isProduction, - sameSite: isProduction ? "strict" : "lax", - path: "/", + httpOnly: true, + secure: isProduction, + sameSite: isProduction ? "strict" : "lax", + path: "/", } as const; +// --------------------------------------------------------------------------- +// Redis +// --------------------------------------------------------------------------- + +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 client error:", error); + }); + + return redisClient; +} + // --------------------------------------------------------------------------- // Validation Schemas // --------------------------------------------------------------------------- const ChallengeRequestSchema = z.object({ - address: z.string().min(1).max(128), + address: z.string().min(1).max(128), }); const VerifyRequestSchema = z.object({ - address: z.string().min(1).max(128), - signature: z.union([ - z.string().min(1).max(1024), - z.object({ - signature: z.string().min(1).max(1024), - }), - ]), + address: z.string().min(1).max(128), + signature: z.union([ + z.string().min(1).max(1024), + z.object({ + signature: z.string().min(1).max(1024), + }), + ]), }); const RefreshRequestSchema = z.object({ - refresh_token: z.string().optional(), + refresh_token: z.string().optional(), }); // --------------------------------------------------------------------------- -// Pure Helpers (exported for testing) +// Helpers // --------------------------------------------------------------------------- -/** - * Validates a Stellar Ed25519 public key by enforcing the canonical StrKey - * checksum. Rejects any address that is not byte-for-byte identical to the - * re-encoded form (catches casing errors, padding, and tampered checksums). - * No whitespace trimming — an address with surrounding spaces is invalid. - */ -export function sanitizeStellarAddress(rawAddress: unknown): string | null { - if (typeof rawAddress !== "string") return null; - if (!/^G[A-Z2-7]{55}$/.test(rawAddress)) return null; +function sha256Hex(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); +} - try { - const decoded = StrKey.decodeEd25519PublicKey(rawAddress); +function blacklistKeyForToken(token: string): string { + return `${SESSION_BLACKLIST_NS}${sha256Hex(token)}`; +} - if (decoded.length !== 32 || !StrKey.isValidEd25519PublicKey(rawAddress)) { - return null; - } +async function isSessionBlacklisted(token: string): Promise { + const client = getRedisClient(); + + if (!client) { + return false; + } + + try { + const result = await Promise.race([ + client.get(blacklistKeyForToken(token)), + new Promise((resolve) => + setTimeout(() => resolve(null), BLACKLIST_TIMEOUT_MS) + ), + ]); + + return result !== null; + } catch { + return false; + } +} - return StrKey.encodeEd25519PublicKey(decoded) === rawAddress - ? rawAddress - : null; - } catch { - return null; - } +async function cleanupExpiredSessions(now: Date): Promise { + await prisma.sessions.deleteMany({ + where: { expires_at: { lte: now } }, + }); } -/** Builds the SEP-53 challenge string for a given address and nonce. */ -export function buildChallenge(address: string, nonce: string): string { - const issuedAt = new Date().toISOString(); - return ( - `Lance wants you to sign in with your Stellar account:\n${address}\n\n` + - `Nonce: ${nonce}\nIssued At: ${issuedAt}` - ); +export function sanitizeStellarAddress( + rawAddress: unknown +): string | null { + if (typeof rawAddress !== "string") { + return null; + } + + const normalized = rawAddress.trim().toUpperCase(); + + if (!/^G[A-Z2-7]{55}$/.test(normalized)) { + return null; + } + + try { + const decoded = StrKey.decodeEd25519PublicKey(normalized); + + if ( + decoded.length !== 32 || + !StrKey.isValidEd25519PublicKey(normalized) + ) { + return null; + } + + Keypair.fromPublicKey(normalized); + + return StrKey.encodeEd25519PublicKey(decoded) === normalized + ? normalized + : null; + } catch { + return null; + } +} + +export function buildChallenge( + address: string, + nonce: string +): string { + const issuedAt = new Date().toISOString(); + + return ( + `Lance wants you to sign in with your Stellar account:\n${address}\n\n` + + `Nonce: ${nonce}\nIssued At: ${issuedAt}` + ); } function buildMessageHash(challenge: string): Buffer { - const payload = Buffer.from(STELLAR_SIGN_PREFIX + challenge, "utf8"); - return crypto.createHash("sha256").update(payload).digest(); + const payload = Buffer.from( + STELLAR_SIGN_PREFIX + challenge, + "utf8" + ); + + return crypto.createHash("sha256").update(payload).digest(); } +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; /** * Safely decodes a signature from either hex or base64 format. * Enforces strict bounds checking: ed25519 signatures are exactly 64 bytes. @@ -133,352 +235,461 @@ function decodeSignature(raw: string): Buffer { return buf; } -function timingSafeEqualStrings(a: string, b: string): boolean { - const aBuf = Buffer.from(a); - const bBuf = Buffer.from(b); - if (aBuf.length !== bBuf.length) return false; - return crypto.timingSafeEqual(aBuf, bBuf); +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 timingSafeEqualStrings( + a: string, + b: string +): boolean { + const aBuf = Buffer.from(a); + const bBuf = Buffer.from(b); + + if (aBuf.length !== bBuf.length) { + return false; + } + + return crypto.timingSafeEqual(aBuf, bBuf); } -/** - * Verifies a SEP-53 / Freighter-style Stellar signature over the - * SHA-256 hash of the prefixed challenge message. - */ export function verifyStellarSignature( - address: string, - challenge: string, - rawSig: string + address: string, + challenge: string, + signature: unknown ): boolean { - try { - const keypair = Keypair.fromPublicKey(address); - const sigBuf = decodeSignature(rawSig); - const hash = buildMessageHash(challenge); - return keypair.verify(hash, sigBuf); - } catch { - return false; - } + try { + const normalizedAddress = + sanitizeStellarAddress(address); + + const signatureBuffer = decodeSignature(signature); + + if (!normalizedAddress || !signatureBuffer) { + return false; + } + + const keypair = + Keypair.fromPublicKey(normalizedAddress); + + return keypair.verify( + buildMessageHash(challenge), + signatureBuffer + ); + } catch { + return false; + } } -/** - * Returns true when the challenge record has not yet expired. - * Uses the supplied `now` date (defaults to current time) so callers can - * inject a deterministic clock in tests. - */ export function isChallengeFresh( - record: { expires_at: Date }, - now: Date = new Date() + record: { expires_at: Date }, + now: Date = new Date() ): boolean { - return record.expires_at.getTime() > now.getTime(); + return record.expires_at.getTime() > now.getTime(); } -/** - * Checks the Redis blacklist for a revoked session JTI. - * Resolves `false` on any error or if Redis exceeds the latency budget, - * so a transient cache outage degrades gracefully rather than locking out - * all users. - */ -export async function isSessionRevoked( - redisClient: { get: (key: string) => Promise }, - jti: string -): Promise { - try { - const result = await Promise.race([ - redisClient.get(`${BLACKLIST_NS}${jti}`), - new Promise((resolve) => - setTimeout(() => resolve(null), BLACKLIST_TIMEOUT_MS) - ), - ]); - return result !== null; - } catch { - return false; - } -} +function extractBearerToken(req: Request): string | null { + const authorization = req.header("authorization"); -function issueAccessToken(address: string, jti: string, role?: string): string { - const secret = process.env.JWT_SECRET; + if (authorization?.startsWith("Bearer ")) { + return authorization + .slice("Bearer ".length) + .trim(); + } - if (!secret) { - throw new Error("JWT_SECRET environment variable is not set"); - } + const cookieHeader = req.header("cookie"); - const options: SignOptions = { - subject: address, - jwtid: jti, - expiresIn: ACCESS_TOKEN_TTL_SEC, - issuer: "lance-marketplace", - audience: "lance-frontend", - }; + if (!cookieHeader) { + return null; + } - return jwt.sign({ address, ...(role ? { role } : {}) }, secret, options); -} + const cookies = cookieHeader + .split(";") + .map((cookie) => cookie.trim()); -async function issueRefreshToken( - address: string, - previousTokenId?: number -): Promise<{ rawToken: string; hashedToken: string }> { - if (previousTokenId !== undefined) { - await prisma.refresh_tokens.update({ - where: { id: previousTokenId }, - data: { revoked: true }, - }); - } + const sessionCookie = cookies.find((cookie) => + cookie.startsWith(`${SESSION_COOKIE_NAME}=`) + ); - const rawToken = crypto.randomBytes(48).toString("base64url"); - const hashedToken = crypto - .createHash("sha256") - .update(rawToken) - .digest("hex"); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_SEC * 1000); - - await prisma.refresh_tokens.create({ - data: { - token_hash: hashedToken, - address, - expires_at: expiresAt, - revoked: false, - }, - }); - - return { rawToken, hashedToken }; -} - -export async function blacklistToken( - jti: string, - expiresAt: number -): Promise { - const ttlSeconds = Math.max(1, expiresAt - Math.floor(Date.now() / 1000)); - await redis.set(`${BLACKLIST_NS}${jti}`, "1", "EX", ttlSeconds, "NX"); -} - -export async function isTokenBlacklisted(jti: string): Promise { - const result = await redis.get(`${BLACKLIST_NS}${jti}`); - return result !== null; + return sessionCookie + ? decodeURIComponent( + sessionCookie.split("=").slice(1).join("=") + ) + : null; } // --------------------------------------------------------------------------- -// createAuthRouter — dependency-injected factory for testing +// JWT Helpers // --------------------------------------------------------------------------- -interface ChallengeRecord { - address: string; - challenge: string; - expires_at: Date; +function issueAccessToken( + address: string, + jti: string, + role?: string +): string { + const secret = process.env.JWT_SECRET; + + if (!secret) { + throw new Error( + "JWT_SECRET environment variable is not set" + ); + } + + const options: SignOptions = { + subject: address, + jwtid: jti, + expiresIn: ACCESS_TOKEN_TTL_SEC, + issuer: "lance-marketplace", + audience: "lance-frontend", + }; + + return jwt.sign( + { address, ...(role ? { role } : {}) }, + secret, + options + ); } -interface AuthRouterOptions { - prismaClient: { - auth_challenges: { - upsert: (args: unknown) => Promise; - findUnique: (args: unknown) => Promise; - deleteMany: (args: unknown) => Promise<{ count: number }>; - }; - sessions: { - create: (args: unknown) => Promise; - findUnique: (args: unknown) => Promise; - deleteMany: (args: unknown) => Promise<{ count: number }>; - }; - }; - redisClient: { - get: (key: string) => Promise; - set: (...args: unknown[]) => Promise; - } | null; +async function issueRefreshToken( + address: string, + previousTokenId?: number +): Promise<{ rawToken: string; hashedToken: string }> { + if (previousTokenId !== undefined) { + await prisma.refresh_tokens.update({ + where: { id: previousTokenId }, + data: { revoked: true }, + }); + } + + const rawToken = crypto + .randomBytes(48) + .toString("base64url"); + + const hashedToken = crypto + .createHash("sha256") + .update(rawToken) + .digest("hex"); + + const expiresAt = new Date( + Date.now() + REFRESH_TOKEN_TTL_SEC * 1000 + ); + + await prisma.refresh_tokens.create({ + data: { + token_hash: hashedToken, + address, + expires_at: expiresAt, + revoked: false, + }, + }); + + return { rawToken, hashedToken }; } -/** - * Returns an Express router wired to the provided persistence clients. - * Designed for unit testing: pass mock prismaClient / redisClient to isolate - * the auth logic from external infrastructure. - */ -export function createAuthRouter(opts: AuthRouterOptions): Router { - const r = Router(); - - // POST /challenge - r.post("/challenge", async (req: Request, res: Response) => { - try { - const parsed = ChallengeRequestSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: "Invalid request body" }); - } - - const address = sanitizeStellarAddress(parsed.data.address); - if (!address) { - return res.status(400).json({ error: "Invalid Stellar address" }); - } - - const nonce = crypto.randomUUID(); - const issuedAt = new Date(); - const expiresAt = new Date(issuedAt.getTime() + CHALLENGE_TTL_MS); - const challenge = buildChallenge(address, nonce); - - await opts.prismaClient.auth_challenges.upsert({ - where: { address }, - update: { challenge, expires_at: expiresAt }, - create: { address, challenge, expires_at: expiresAt }, - }); - - return res.status(200).json({ - challenge, - expires_at: expiresAt.toISOString(), - }); - } catch (err) { - console.error("[auth/challenge]", err); - return res.status(500).json({ error: "Internal server error" }); - } - }); - - // POST /verify - r.post("/verify", async (req: Request, res: Response) => { - try { - const parsed = VerifyRequestSchema.safeParse(req.body); - if (!parsed.success) { - return res.status(400).json({ error: "Invalid request body" }); - } - - const address = sanitizeStellarAddress(parsed.data.address); - if (!address) { - return res.status(400).json({ error: "Invalid Stellar address" }); - } - - let signature = parsed.data.signature; - if (typeof signature === "object" && "signature" in signature) { - signature = signature.signature; - } - - const record = await opts.prismaClient.auth_challenges.findUnique({ - where: { address }, - }); - - // Return 401 (not 404) to avoid leaking whether an address has a pending challenge. - if (!record) { - return res.status(401).json({ error: "Invalid credentials" }); - } - - if (!isChallengeFresh(record)) { - await opts.prismaClient.auth_challenges - .deleteMany({ - where: { - address, - challenge: record.challenge, - expires_at: { gt: new Date(0) }, - }, - }) - .catch(() => {}); - return res.status(401).json({ error: "Challenge expired" }); - } - - let isValid = verifyStellarSignature(address, record.challenge, signature); - - // Dev-sandbox mock: accept the literal "mock-signature" or the challenge - // string itself so local tooling can exercise auth flows without a wallet. - if (!isValid && process.env.NODE_ENV !== "production") { - if ( - signature === "mock-signature" || - timingSafeEqualStrings(signature, record.challenge) - ) { - isValid = true; - } - } - - if (!isValid) { - return res.status(401).json({ error: "Invalid signature" }); - } - - // Atomically consume the challenge. count === 0 means another concurrent - // request already used it (TOCTOU guard). - const deleted = await opts.prismaClient.auth_challenges.deleteMany({ - where: { - address, - challenge: record.challenge, - expires_at: { gt: new Date() }, - }, - }); - - if (deleted.count === 0) { - return res.status(401).json({ error: "Challenge already consumed" }); - } - - const sessionToken = crypto.randomBytes(48).toString("base64url"); - const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_SEC * 1000); +export async function blacklistToken( + jti: string, + expiresAt: number +): Promise { + const client = getRedisClient(); + + if (!client) { + return; + } + + const ttlSeconds = Math.max( + 1, + expiresAt - Math.floor(Date.now() / 1000) + ); + + await client.set( + `${BLACKLIST_NS}${jti}`, + "1", + "EX", + ttlSeconds, + "NX" + ); +} - await opts.prismaClient.sessions.create({ - data: { token: sessionToken, address, expires_at: expiresAt }, - }); +export async function isTokenBlacklisted( + jti: string +): Promise { + const client = getRedisClient(); - res.cookie(REFRESH_TOKEN_COOKIE, sessionToken, { - ...COOKIE_BASE_OPTIONS, - maxAge: REFRESH_TOKEN_TTL_SEC * 1000, - }); + if (!client) { + return false; + } - return res.status(200).json({ - token: sessionToken, - token_type: "Bearer", - expires_in: REFRESH_TOKEN_TTL_SEC, - }); - } catch (err) { - console.error("[auth/verify]", err); - return res.status(500).json({ error: "Internal server error" }); - } - }); + const result = await client.get( + `${BLACKLIST_NS}${jti}` + ); - return r; + return result !== null; } // --------------------------------------------------------------------------- -// Route: POST /challenge (production router) +// Routes // --------------------------------------------------------------------------- interface ChallengeBody { - address: string; + address: string; } router.post( - "/challenge", - async (req: Request<{}, {}, ChallengeBody>, res: Response) => { - try { - const parsed = ChallengeRequestSchema.safeParse(req.body); - - if (!parsed.success) { - return res.status(400).json({ error: "Invalid request body" }); - } - - const address = sanitizeStellarAddress(parsed.data.address); - - if (!address) { - return res.status(400).json({ error: "Invalid Stellar address" }); - } - - const nonce = crypto.randomUUID(); - const issuedAt = new Date(); - const expiresAt = new Date(issuedAt.getTime() + CHALLENGE_TTL_MS); - const challenge = buildChallenge(address, nonce); - - await prisma.auth_challenges.upsert({ - where: { address }, - update: { challenge, expires_at: expiresAt }, - create: { address, challenge, expires_at: expiresAt }, - }); - - return res.status(200).json({ - challenge, - expires_at: expiresAt.toISOString(), - }); - } catch (error) { - console.error("[auth/challenge]", error); - return res.status(500).json({ error: "Internal server error" }); - } - } + "/challenge", + async ( + req: Request<{}, {}, ChallengeBody>, + res: Response + ) => { + try { + const parsed = + ChallengeRequestSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid request body", + }); + } + + const address = sanitizeStellarAddress( + parsed.data.address + ); + + if (!address) { + return res.status(400).json({ + error: "Invalid Stellar address", + }); + } + + const nonce = crypto.randomUUID(); + const challenge = buildChallenge(address, nonce); + + const expiresAt = new Date( + Date.now() + CHALLENGE_TTL_MS + ); + + await prisma.$transaction(async (tx) => { + 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, + }, + }); + }); + + return res.json({ + challenge, + expires_at: expiresAt.toISOString(), + }); + } catch (error) { + console.error("[auth/challenge]", error); + + return res.status(500).json({ + error: "Internal server error", + }); + } + } ); -// --------------------------------------------------------------------------- -// Route: POST /verify (production router) -// --------------------------------------------------------------------------- - interface VerifyBody { - address: string; - signature: string | { signature: string }; + address: string; + signature: string | { signature: string }; } router.post( + "/verify", + async ( + req: Request<{}, {}, VerifyBody>, + res: Response + ) => { + try { + const parsed = + VerifyRequestSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid request body", + }); + } + + const address = sanitizeStellarAddress( + parsed.data.address + ); + + if (!address) { + return res.status(400).json({ + error: "Invalid Stellar address", + }); + } + + let signature = parsed.data.signature; + + if ( + typeof signature === "object" && + "signature" in signature + ) { + signature = signature.signature; + } + + const challengeRecord = + await prisma.auth_challenges.findUnique({ + where: { address }, + }); + + if (!challengeRecord) { + return res.status(401).json({ + error: "Invalid credentials", + }); + } + + if (!isChallengeFresh(challengeRecord)) { + await prisma.auth_challenges + .deleteMany({ + where: { + address, + challenge: challengeRecord.challenge, + }, + }) + .catch(() => {}); + + return res.status(401).json({ + error: "Challenge expired", + }); + } + + let isValid = verifyStellarSignature( + address, + challengeRecord.challenge, + signature + ); + + if (!isValid && process.env.NODE_ENV !== "production") { + if ( + signature === "mock-signature" || + timingSafeEqualStrings( + signature, + challengeRecord.challenge + ) + ) { + isValid = true; + } + } + + if (!isValid) { + return res.status(401).json({ + error: "Invalid signature", + }); + } + + const deleted = + await prisma.auth_challenges.deleteMany({ + where: { + address, + challenge: challengeRecord.challenge, + expires_at: { gt: new Date() }, + }, + }); + + if (deleted.count === 0) { + return res.status(401).json({ + error: "Challenge already consumed", + }); + } + + const accessJti = crypto.randomUUID(); + + const accessToken = issueAccessToken( + address, + accessJti + ); + + const { rawToken: refreshToken } = + await issueRefreshToken(address); + + const sessionToken = crypto.randomUUID(); + + const sessionExpiresAt = new Date( + Date.now() + SESSION_TTL_MS + ); + + await prisma.sessions.create({ + data: { + token: sessionToken, + address, + expires_at: sessionExpiresAt, + }, + }); + + res.cookie(ACCESS_TOKEN_COOKIE, accessToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: ACCESS_TOKEN_TTL_SEC * 1000, + }); + + res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: REFRESH_TOKEN_TTL_SEC * 1000, + }); + + res.cookie(SESSION_COOKIE_NAME, sessionToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: SESSION_TTL_MS, + }); + + return res.status(200).json({ + access_token: accessToken, + refresh_token: refreshToken, + session_token: sessionToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_TTL_SEC, + }); + } catch (error) { + console.error("[auth/verify]", error); + + return res.status(500).json({ + error: "Internal server error", + }); + } + } "/verify", async (req: Request<{}, {}, VerifyBody>, res: Response) => { try { @@ -575,151 +786,327 @@ router.post( } ); -// --------------------------------------------------------------------------- -// Route: POST /refresh -// --------------------------------------------------------------------------- - interface RefreshBody { - refresh_token?: string; + refresh_token?: string; } router.post( - "/refresh", - async (req: Request<{}, {}, RefreshBody>, res: Response) => { - try { - const parsed = RefreshRequestSchema.safeParse(req.body); - - if (!parsed.success) { - return res.status(400).json({ error: "Invalid request body" }); - } - - let refreshToken = parsed.data.refresh_token; - - if (!refreshToken) { - refreshToken = req.cookies?.[REFRESH_TOKEN_COOKIE]; - } - - if (!refreshToken || typeof refreshToken !== "string") { - return res.status(400).json({ error: "refresh_token is required" }); - } - - const incomingHash = crypto - .createHash("sha256") - .update(refreshToken) - .digest("hex"); - - const record = await prisma.refresh_tokens.findUnique({ - where: { token_hash: incomingHash }, - }); - - if (!record) { - return res.status(401).json({ error: "Invalid refresh token" }); - } - - if (record.revoked) { - console.warn( - `[auth/refresh] Revoked token replay attempt for ${record.address}` - ); - return res - .status(401) - .json({ error: "Refresh token has been revoked" }); - } - - if (record.expires_at.getTime() <= Date.now()) { - return res.status(401).json({ error: "Refresh token expired" }); - } - - const newAccessJti = crypto.randomUUID(); - const newAccessToken = issueAccessToken(record.address, newAccessJti); - const { rawToken: newRefreshToken } = await issueRefreshToken( - record.address, - record.id - ); - - res.cookie(ACCESS_TOKEN_COOKIE, newAccessToken, { - ...COOKIE_BASE_OPTIONS, - maxAge: ACCESS_TOKEN_TTL_SEC * 1000, - }); - - res.cookie(REFRESH_TOKEN_COOKIE, newRefreshToken, { - ...COOKIE_BASE_OPTIONS, - maxAge: REFRESH_TOKEN_TTL_SEC * 1000, - }); - - return res.status(200).json({ - access_token: newAccessToken, - refresh_token: newRefreshToken, - token_type: "Bearer", - expires_in: ACCESS_TOKEN_TTL_SEC, - }); - } catch (error) { - console.error("[auth/refresh]", error); - return res.status(500).json({ error: "Internal server error" }); - } - } + "/refresh", + async ( + req: Request<{}, {}, RefreshBody>, + res: Response + ) => { + try { + const parsed = + RefreshRequestSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid request body", + }); + } + + let refreshToken = + parsed.data.refresh_token; + + if (!refreshToken) { + refreshToken = + req.cookies?.[REFRESH_TOKEN_COOKIE]; + } + + if ( + !refreshToken || + typeof refreshToken !== "string" + ) { + return res.status(400).json({ + error: "refresh_token is required", + }); + } + + const incomingHash = crypto + .createHash("sha256") + .update(refreshToken) + .digest("hex"); + + const record = + await prisma.refresh_tokens.findUnique({ + where: { + token_hash: incomingHash, + }, + }); + + if (!record) { + return res.status(401).json({ + error: "Invalid refresh token", + }); + } + + if (record.revoked) { + return res.status(401).json({ + error: + "Refresh token has been revoked", + }); + } + + if ( + record.expires_at.getTime() <= + Date.now() + ) { + return res.status(401).json({ + error: "Refresh token expired", + }); + } + + const newAccessJti = + crypto.randomUUID(); + + const newAccessToken = + issueAccessToken( + record.address, + newAccessJti + ); + + const { + rawToken: newRefreshToken, + } = await issueRefreshToken( + record.address, + record.id + ); + + res.cookie( + ACCESS_TOKEN_COOKIE, + newAccessToken, + { + ...COOKIE_BASE_OPTIONS, + maxAge: + ACCESS_TOKEN_TTL_SEC * 1000, + } + ); + + res.cookie( + REFRESH_TOKEN_COOKIE, + newRefreshToken, + { + ...COOKIE_BASE_OPTIONS, + maxAge: + REFRESH_TOKEN_TTL_SEC * 1000, + } + ); + + return res.status(200).json({ + access_token: newAccessToken, + refresh_token: newRefreshToken, + token_type: "Bearer", + expires_in: ACCESS_TOKEN_TTL_SEC, + }); + } catch (error) { + console.error("[auth/refresh]", error); + + return res.status(500).json({ + error: "Internal server error", + }); + } + } ); -// --------------------------------------------------------------------------- -// Route: POST /logout -// --------------------------------------------------------------------------- - -router.post("/logout", async (req: Request, res: Response) => { - try { - let rawAccessToken = req.cookies?.[ACCESS_TOKEN_COOKIE]; - const authHeader = req.headers.authorization; - - if (!rawAccessToken && authHeader?.startsWith("Bearer ")) { - rawAccessToken = authHeader.slice(7); - } - - let refreshToken = req.cookies?.[REFRESH_TOKEN_COOKIE]; - const body = req.body as RefreshBody; - - if (!refreshToken && body.refresh_token) { - refreshToken = body.refresh_token; - } - - if (rawAccessToken) { - const secret = process.env.JWT_SECRET; - - if (secret) { - try { - const decoded = jwt.verify(rawAccessToken, secret, { - issuer: "lance-marketplace", - audience: "lance-frontend", - }) as JwtPayload; - - if (decoded.jti && decoded.exp) { - await blacklistToken(decoded.jti, decoded.exp); - } - } catch { - // Ignore invalid/expired token - } - } - } - - if (refreshToken && typeof refreshToken === "string") { - const hash = crypto - .createHash("sha256") - .update(refreshToken) - .digest("hex"); - - await prisma.refresh_tokens - .updateMany({ - where: { token_hash: hash, revoked: false }, - data: { revoked: true }, - }) - .catch(() => {}); - } - - res.clearCookie(ACCESS_TOKEN_COOKIE, COOKIE_BASE_OPTIONS); - res.clearCookie(REFRESH_TOKEN_COOKIE, COOKIE_BASE_OPTIONS); +router.post( + "/logout", + async (req: Request, res: Response) => { + try { + let rawAccessToken = + req.cookies?.[ACCESS_TOKEN_COOKIE]; + + const authHeader = + req.headers.authorization; + + if ( + !rawAccessToken && + authHeader?.startsWith("Bearer ") + ) { + rawAccessToken = + authHeader.slice(7); + } + + let refreshToken = + req.cookies?.[ + REFRESH_TOKEN_COOKIE + ]; + + const body = + req.body as RefreshBody; + + if ( + !refreshToken && + body.refresh_token + ) { + refreshToken = + body.refresh_token; + } + + if (rawAccessToken) { + const secret = + process.env.JWT_SECRET; + + if (secret) { + try { + const decoded = jwt.verify( + rawAccessToken, + secret, + { + issuer: + "lance-marketplace", + audience: + "lance-frontend", + } + ) as JwtPayload; + + if ( + decoded.jti && + decoded.exp + ) { + await blacklistToken( + decoded.jti, + decoded.exp + ); + } + } catch { + // Ignore invalid/expired token + } + } + } + + if ( + refreshToken && + typeof refreshToken === "string" + ) { + const hash = crypto + .createHash("sha256") + .update(refreshToken) + .digest("hex"); + + await prisma.refresh_tokens + .updateMany({ + where: { + token_hash: hash, + revoked: false, + }, + data: { + revoked: true, + }, + }) + .catch(() => {}); + } + + const sessionToken = + extractBearerToken(req); + + if (sessionToken) { + const client = + getRedisClient(); + + if (client) { + await client.set( + blacklistKeyForToken( + sessionToken + ), + "1", + "EX", + REFRESH_TOKEN_TTL_SEC, + "NX" + ); + } + } + + res.clearCookie( + ACCESS_TOKEN_COOKIE, + COOKIE_BASE_OPTIONS + ); + + res.clearCookie( + REFRESH_TOKEN_COOKIE, + COOKIE_BASE_OPTIONS + ); + + res.clearCookie( + SESSION_COOKIE_NAME, + COOKIE_BASE_OPTIONS + ); + + return res.status(200).json({ + message: + "Logged out successfully", + }); + } catch (error) { + console.error("[auth/logout]", error); + + return res.status(500).json({ + error: "Internal server error", + }); + } + } +); - return res.status(200).json({ message: "Logged out successfully" }); - } catch (error) { - console.error("[auth/logout]", error); - return 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", + }); + } + + return res.json({ + address: session.address, + expires_at: + session.expires_at.toISOString(), + }); + } catch (error) { + console.error("[auth/session]", error); + + return res.status(500).json({ + error: "Internal server error", + }); + } + } +); -export default router; +export default router; \ No newline at end of file