From f6ec3e36049f9e187f0d63c4cf30a8b2d4bfcee0 Mon Sep 17 00:00:00 2001 From: devjayy43 Date: Fri, 29 May 2026 16:35:41 +0100 Subject: [PATCH] feat(auth): export SEP-53 helpers, add createAuthRouter DI factory, RBAC middleware, and session-hijacking guard - Export sanitizeStellarAddress (no-trim, strict checksum), buildChallenge, verifyStellarSignature, isChallengeFresh, isSessionRevoked (5ms timeout budget), and createAuthRouter for dependency-injection in tests (#474, #473) - createAuthRouter wires to injected prismaClient/redisClient, uses the sessions table, returns 401 for missing/expired challenges, and atomically consumes each challenge with deleteMany to block replay attacks (#473, #472) - Add backend/src/middleware/rbac.ts: requireRole(...roles) guards routes by the role claim in the JWT (freelancer | client | admin) (#471) - authGuard: add role? field to AuthRequest; validate address claim is a canonical Ed25519 Stellar key before trusting it (session-hijacking guard for #472); import UserRole type from rbac.ts Closes #471, #472, #473, #474 --- backend/src/middleware/authGuard.ts | 12 +- backend/src/middleware/rbac.ts | 36 ++ backend/src/routes/auth.ts | 783 ++++++++++++++-------------- 3 files changed, 425 insertions(+), 406 deletions(-) create mode 100644 backend/src/middleware/rbac.ts diff --git a/backend/src/middleware/authGuard.ts b/backend/src/middleware/authGuard.ts index e6280c36..caae7093 100644 --- a/backend/src/middleware/authGuard.ts +++ b/backend/src/middleware/authGuard.ts @@ -23,11 +23,13 @@ import { Request, Response, NextFunction } from "express"; import jwt, { JwtPayload } from "jsonwebtoken"; +import { StrKey } from "@stellar/stellar-sdk"; import { isTokenBlacklisted } from "../routes/auth"; +import type { UserRole } from "./rbac"; /** Augments Express Request with the decoded JWT payload. */ export interface AuthRequest extends Request { - auth?: JwtPayload & { address: string; jti: string }; + auth?: JwtPayload & { address: string; jti: string; role?: UserRole }; } const ACCESS_TOKEN_COOKIE = "lance_access_token"; @@ -74,6 +76,14 @@ export async function authGuard( return; } + // Validate the address claim is a real Stellar key. A tampered or + // manually-crafted token that carries a non-address payload fails here + // before it can reach any business logic (session-hijacking guard). + if (!StrKey.isValidEd25519PublicKey(decoded.address)) { + res.status(401).json({ error: "Token contains invalid address claim" }); + return; + } + // Redis blacklist check — single GET, O(1), target < 1 ms. const revoked = await isTokenBlacklisted(decoded.jti).catch(() => false); if (revoked) { diff --git a/backend/src/middleware/rbac.ts b/backend/src/middleware/rbac.ts new file mode 100644 index 00000000..d1ab6e73 --- /dev/null +++ b/backend/src/middleware/rbac.ts @@ -0,0 +1,36 @@ +/** + * rbac.ts — Role-Based Access Control middleware (Freelancer vs Client) + * + * Usage: + * router.get("/my-jobs", authGuard, requireRole("freelancer"), handler); + * router.post("/post-job", authGuard, requireRole("client", "admin"), handler); + */ + +import { Request, Response, NextFunction } from "express"; + +export type UserRole = "freelancer" | "client" | "admin"; + +/** + * Returns middleware that rejects the request with 403 if the authenticated + * user's role (carried in `req.auth.role`) is not in the allowed set. + * Must be applied after `authGuard`, which populates `req.auth`. + */ +export function requireRole(...allowed: UserRole[]) { + return (req: Request, res: Response, next: NextFunction): void => { + const role = (req as Request & { auth?: { role?: string } }).auth?.role as + | UserRole + | undefined; + + if (!role) { + res.status(403).json({ error: "Role information missing from token" }); + return; + } + + if (!allowed.includes(role)) { + res.status(403).json({ error: "Insufficient permissions" }); + return; + } + + next(); + }; +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 6f1f5ac3..85da92bc 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -29,6 +29,9 @@ const BLACKLIST_NS = "jwt:blacklist:"; const ACCESS_TOKEN_COOKIE = "lance_access_token"; const REFRESH_TOKEN_COOKIE = "lance_refresh_token"; +/** 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 = { @@ -61,71 +64,118 @@ const RefreshRequestSchema = z.object({ }); // --------------------------------------------------------------------------- -// Helpers +// Pure Helpers (exported for testing) // --------------------------------------------------------------------------- -function sanitizeStellarAddress(rawAddress: unknown): string | null { - if (typeof rawAddress !== "string") { - return null; - } - - const address = rawAddress.trim(); - - if (!/^G[A-Z2-7]{55}$/.test(address)) { - return null; - } +/** + * 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; try { - const decoded = StrKey.decodeEd25519PublicKey(address); + const decoded = StrKey.decodeEd25519PublicKey(rawAddress); - if ( - decoded.length !== 32 || - !StrKey.isValidEd25519PublicKey(address) - ) { + if (decoded.length !== 32 || !StrKey.isValidEd25519PublicKey(rawAddress)) { return null; } - return StrKey.encodeEd25519PublicKey(decoded) === address - ? address + return StrKey.encodeEd25519PublicKey(decoded) === rawAddress + ? rawAddress : null; } catch { return null; } } -function buildMessageHash(challenge: string): Buffer { - const payload = Buffer.from( - STELLAR_SIGN_PREFIX + challenge, - "utf8" +/** 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}` ); +} +function buildMessageHash(challenge: string): Buffer { + const payload = Buffer.from(STELLAR_SIGN_PREFIX + challenge, "utf8"); return crypto.createHash("sha256").update(payload).digest(); } function decodeSignature(raw: string): Buffer { const trimmed = raw.trim(); - - const hexPattern = /^[0-9a-fA-F]+$/; - - if (hexPattern.test(trimmed) && trimmed.length % 2 === 0) { + if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) { return Buffer.from(trimmed, "hex"); } - return Buffer.from(trimmed, "base64"); } 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); +} - if (aBuf.length !== bBuf.length) { +/** + * 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 +): boolean { + try { + const keypair = Keypair.fromPublicKey(address); + const sigBuf = decodeSignature(rawSig); + const hash = buildMessageHash(challenge); + return keypair.verify(hash, sigBuf); + } catch { return false; } +} - return crypto.timingSafeEqual(aBuf, bBuf); +/** + * 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() +): boolean { + return record.expires_at.getTime() > now.getTime(); } -function issueAccessToken(address: string, jti: string): string { +/** + * 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 issueAccessToken(address: string, jti: string, role?: string): string { const secret = process.env.JWT_SECRET; if (!secret) { @@ -140,7 +190,7 @@ function issueAccessToken(address: string, jti: string): string { audience: "lance-frontend", }; - return jwt.sign({ address }, secret, options); + return jwt.sign({ address, ...(role ? { role } : {}) }, secret, options); } async function issueRefreshToken( @@ -149,25 +199,17 @@ async function issueRefreshToken( ): Promise<{ rawToken: string; hashedToken: string }> { if (previousTokenId !== undefined) { await prisma.refresh_tokens.update({ - where: { - id: previousTokenId, - }, - data: { - revoked: true, - }, + 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 - ); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_SEC * 1000); await prisma.refresh_tokens.create({ data: { @@ -178,40 +220,192 @@ async function issueRefreshToken( }, }); - return { - rawToken, - hashedToken, - }; + return { rawToken, hashedToken }; } -async function blacklistToken( +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" - ); + const ttlSeconds = Math.max(1, expiresAt - Math.floor(Date.now() / 1000)); + await redis.set(`${BLACKLIST_NS}${jti}`, "1", "EX", ttlSeconds, "NX"); } -async function isTokenBlacklisted( - jti: string -): Promise { +export async function isTokenBlacklisted(jti: string): Promise { const result = await redis.get(`${BLACKLIST_NS}${jti}`); - return result !== null; } // --------------------------------------------------------------------------- -// Route: POST /challenge +// createAuthRouter — dependency-injected factory for testing +// --------------------------------------------------------------------------- + +interface ChallengeRecord { + address: string; + challenge: string; + expires_at: Date; +} + +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; +} + +/** + * 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); + + await opts.prismaClient.sessions.create({ + data: { token: sessionToken, address, expires_at: expiresAt }, + }); + + res.cookie(REFRESH_TOKEN_COOKIE, sessionToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: REFRESH_TOKEN_TTL_SEC * 1000, + }); + + 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" }); + } + }); + + return r; +} + +// --------------------------------------------------------------------------- +// Route: POST /challenge (production router) // --------------------------------------------------------------------------- interface ChallengeBody { @@ -220,59 +414,29 @@ interface ChallengeBody { router.post( "/challenge", - async ( - req: Request<{}, {}, ChallengeBody>, - res: Response - ) => { + async (req: Request<{}, {}, ChallengeBody>, res: Response) => { try { - const parsed = - ChallengeRequestSchema.safeParse(req.body); + const parsed = ChallengeRequestSchema.safeParse(req.body); if (!parsed.success) { - return res.status(400).json({ - error: "Invalid request body", - }); + return res.status(400).json({ error: "Invalid request body" }); } - const address = sanitizeStellarAddress( - parsed.data.address - ); + const address = sanitizeStellarAddress(parsed.data.address); if (!address) { - return res.status(400).json({ - error: "Invalid Stellar 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 = - `Lance wants you to sign in with your Stellar account:\n` + - `${address}\n\n` + - `Nonce: ${nonce}\n` + - `Issued At: ${issuedAt.toISOString()}`; + const expiresAt = new Date(issuedAt.getTime() + CHALLENGE_TTL_MS); + const challenge = buildChallenge(address, nonce); await prisma.auth_challenges.upsert({ - where: { - address, - }, - update: { - challenge, - issued_at: issuedAt, - expires_at: expiresAt, - }, - create: { - address, - challenge, - issued_at: issuedAt, - expires_at: expiresAt, - }, + where: { address }, + update: { challenge, expires_at: expiresAt }, + create: { address, challenge, expires_at: expiresAt }, }); return res.status(200).json({ @@ -281,16 +445,13 @@ router.post( }); } catch (error) { console.error("[auth/challenge]", error); - - return res.status(500).json({ - error: "Internal server error", - }); + return res.status(500).json({ error: "Internal server error" }); } } ); // --------------------------------------------------------------------------- -// Route: POST /verify +// Route: POST /verify (production router) // --------------------------------------------------------------------------- interface VerifyBody { @@ -300,151 +461,76 @@ interface VerifyBody { router.post( "/verify", - async ( - req: Request<{}, {}, VerifyBody>, - res: Response - ) => { + async (req: Request<{}, {}, VerifyBody>, res: Response) => { try { - const parsed = - VerifyRequestSchema.safeParse(req.body); + const parsed = VerifyRequestSchema.safeParse(req.body); if (!parsed.success) { - return res.status(400).json({ - error: "Invalid request body", - }); + return res.status(400).json({ error: "Invalid request body" }); } - const address = sanitizeStellarAddress( - parsed.data.address - ); + const address = sanitizeStellarAddress(parsed.data.address); if (!address) { - return res.status(400).json({ - error: "Invalid Stellar address", - }); + return res.status(400).json({ error: "Invalid Stellar address" }); } let signature = parsed.data.signature; - if ( - typeof signature === "object" && - "signature" in signature - ) { + if (typeof signature === "object" && "signature" in signature) { signature = signature.signature; } - const challengeRecord = - await prisma.auth_challenges.findUnique({ - where: { - address, - }, - }); + const challengeRecord = await prisma.auth_challenges.findUnique({ + where: { address }, + }); if (!challengeRecord) { - return res.status(404).json({ - error: "No challenge found", - }); + return res.status(404).json({ error: "No challenge found" }); } - if ( - challengeRecord.expires_at.getTime() <= - Date.now() - ) { + if (challengeRecord.expires_at.getTime() <= Date.now()) { await prisma.auth_challenges - .delete({ - where: { - address, - }, - }) + .delete({ where: { address } }) .catch(() => {}); - return res.status(401).json({ - error: "Challenge expired", - }); + return res.status(401).json({ error: "Challenge expired" }); } - let isValid = false; - - try { - const keypair = - Keypair.fromPublicKey(address); - - const signatureBuffer = - decodeSignature(signature); - - const messageHash = buildMessageHash( - challengeRecord.challenge - ); - - isValid = keypair.verify( - messageHash, - signatureBuffer - ); - } catch (err) { - console.warn( - "[auth/verify] Signature verification failed:", - err - ); - - isValid = false; - } + let isValid = verifyStellarSignature( + address, + challengeRecord.challenge, + signature + ); - if ( - !isValid && - process.env.NODE_ENV !== "production" - ) { + if (!isValid && process.env.NODE_ENV !== "production") { if ( signature === "mock-signature" || - timingSafeEqualStrings( - signature, - challengeRecord.challenge - ) + timingSafeEqualStrings(signature, challengeRecord.challenge) ) { isValid = true; } } if (!isValid) { - return res.status(401).json({ - error: "Invalid signature", - }); + return res.status(401).json({ error: "Invalid signature" }); } - await prisma.auth_challenges.delete({ - where: { - address, - }, - }); + await prisma.auth_challenges.delete({ where: { address } }); const accessJti = crypto.randomUUID(); + const accessToken = issueAccessToken(address, accessJti); + const { rawToken: refreshToken } = await issueRefreshToken(address); - const accessToken = issueAccessToken( - address, - accessJti - ); - - const { rawToken: refreshToken } = - await issueRefreshToken(address); - - res.cookie( - ACCESS_TOKEN_COOKIE, - accessToken, - { - ...COOKIE_BASE_OPTIONS, - maxAge: - ACCESS_TOKEN_TTL_SEC * 1000, - } - ); + 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(REFRESH_TOKEN_COOKIE, refreshToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: REFRESH_TOKEN_TTL_SEC * 1000, + }); return res.status(200).json({ access_token: accessToken, @@ -454,10 +540,7 @@ router.post( }); } catch (error) { console.error("[auth/verify]", error); - - return res.status(500).json({ - error: "Internal server error", - }); + return res.status(500).json({ error: "Internal server error" }); } } ); @@ -472,37 +555,22 @@ interface RefreshBody { router.post( "/refresh", - async ( - req: Request<{}, {}, RefreshBody>, - res: Response - ) => { + async (req: Request<{}, {}, RefreshBody>, res: Response) => { try { - const parsed = - RefreshRequestSchema.safeParse(req.body); + const parsed = RefreshRequestSchema.safeParse(req.body); if (!parsed.success) { - return res.status(400).json({ - error: "Invalid request body", - }); + return res.status(400).json({ error: "Invalid request body" }); } - let refreshToken = - parsed.data.refresh_token; + let refreshToken = parsed.data.refresh_token; if (!refreshToken) { - refreshToken = - req.cookies?.[ - REFRESH_TOKEN_COOKIE - ]; + refreshToken = req.cookies?.[REFRESH_TOKEN_COOKIE]; } - if ( - !refreshToken || - typeof refreshToken !== "string" - ) { - return res.status(400).json({ - error: "refresh_token is required", - }); + if (!refreshToken || typeof refreshToken !== "string") { + return res.status(400).json({ error: "refresh_token is required" }); } const incomingHash = crypto @@ -510,74 +578,43 @@ router.post( .update(refreshToken) .digest("hex"); - const record = - await prisma.refresh_tokens.findUnique({ - where: { - token_hash: incomingHash, - }, - }); + const record = await prisma.refresh_tokens.findUnique({ + where: { token_hash: incomingHash }, + }); if (!record) { - return res.status(401).json({ - error: "Invalid refresh token", - }); + 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", - }); + 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", - }); + 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( + 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(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, - } - ); + res.cookie(REFRESH_TOKEN_COOKIE, newRefreshToken, { + ...COOKIE_BASE_OPTIONS, + maxAge: REFRESH_TOKEN_TTL_SEC * 1000, + }); return res.status(200).json({ access_token: newAccessToken, @@ -587,10 +624,7 @@ router.post( }); } catch (error) { console.error("[auth/refresh]", error); - - return res.status(500).json({ - error: "Internal server error", - }); + return res.status(500).json({ error: "Internal server error" }); } } ); @@ -599,124 +633,63 @@ router.post( // 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); - } +router.post("/logout", async (req: Request, res: Response) => { + try { + let rawAccessToken = req.cookies?.[ACCESS_TOKEN_COOKIE]; + const authHeader = req.headers.authorization; - let refreshToken = - req.cookies?.[ - REFRESH_TOKEN_COOKIE - ]; + if (!rawAccessToken && authHeader?.startsWith("Bearer ")) { + rawAccessToken = authHeader.slice(7); + } - const body = - req.body as RefreshBody; + let refreshToken = req.cookies?.[REFRESH_TOKEN_COOKIE]; + const body = req.body as RefreshBody; - if ( - !refreshToken && - body.refresh_token - ) { - refreshToken = - body.refresh_token; - } + if (!refreshToken && body.refresh_token) { + refreshToken = body.refresh_token; + } + + if (rawAccessToken) { + const secret = process.env.JWT_SECRET; - 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 (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 - ); - - return res.status(200).json({ - message: "Logged out successfully", - }); - } catch (error) { - console.error("[auth/logout]", error); + if (refreshToken && typeof refreshToken === "string") { + const hash = crypto + .createHash("sha256") + .update(refreshToken) + .digest("hex"); - return res.status(500).json({ - error: "Internal server error", - }); + await prisma.refresh_tokens + .updateMany({ + where: { token_hash: hash, revoked: false }, + data: { revoked: true }, + }) + .catch(() => {}); } - } -); -// --------------------------------------------------------------------------- -// Utility Exports -// --------------------------------------------------------------------------- + res.clearCookie(ACCESS_TOKEN_COOKIE, COOKIE_BASE_OPTIONS); + res.clearCookie(REFRESH_TOKEN_COOKIE, COOKIE_BASE_OPTIONS); -export { isTokenBlacklisted, blacklistToken }; + 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" }); + } +}); -export default router; \ No newline at end of file +export default router;