From e793a59570d42c7aef8f63929a3c020fd5508e84 Mon Sep 17 00:00:00 2001 From: Nico Date: Tue, 26 May 2026 00:40:06 -0700 Subject: [PATCH] fix: fall back to session cookies for stale tokens --- backend/middleware/auth.js | 23 ++--- backend/middleware/optional-auth.js | 18 ++-- backend/tests/auth.middleware.test.js | 118 ++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 backend/tests/auth.middleware.test.js diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 08e1ae6..c936b56 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -8,27 +8,30 @@ const { logError } = require("../utils/logger"); async function auth(req, res, next) { const header = req.headers.authorization || ""; const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null; + const cookies = parseCookieHeader(req.headers.cookie); + const sid = cookies[cookieName()]; if (token) { const jwtSecret = process.env.JWT_SECRET; - if (!jwtSecret) { + if (!jwtSecret && !sid) { logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured")); return sendError(res, 500, "Authentication is unavailable"); } - try { - const decoded = jwt.verify(token, jwtSecret); - req.user = decoded; // id + role - return next(); - } catch (err) { - return sendError(res, 401, "Invalid or expired token"); + if (jwtSecret) { + try { + const decoded = jwt.verify(token, jwtSecret); + req.user = decoded; // id + role + return next(); + } catch (err) { + if (!sid) { + return sendError(res, 401, "Invalid or expired token"); + } + } } } try { - const cookies = parseCookieHeader(req.headers.cookie); - const sid = cookies[cookieName()]; - if (!sid) { return sendError(res, 401, "Missing authentication"); } diff --git a/backend/middleware/optional-auth.js b/backend/middleware/optional-auth.js index ddbf932..fb0d93d 100644 --- a/backend/middleware/optional-auth.js +++ b/backend/middleware/optional-auth.js @@ -10,16 +10,14 @@ async function optionalAuth(req, res, next) { if (token) { const jwtSecret = process.env.JWT_SECRET; - if (!jwtSecret) { - return next(); - } - - try { - const decoded = jwt.verify(token, jwtSecret); - req.user = decoded; - return next(); - } catch (err) { - return next(); + if (jwtSecret) { + try { + const decoded = jwt.verify(token, jwtSecret); + req.user = decoded; + return next(); + } catch (err) { + // Continue to the session cookie fallback below. + } } } diff --git a/backend/tests/auth.middleware.test.js b/backend/tests/auth.middleware.test.js new file mode 100644 index 0000000..2ec74df --- /dev/null +++ b/backend/tests/auth.middleware.test.js @@ -0,0 +1,118 @@ +jest.mock("jsonwebtoken", () => ({ + verify: jest.fn(), +})); + +jest.mock("../models/session.model", () => ({ + getActiveSessionWithUser: jest.fn(), +})); + +jest.mock("../utils/logger", () => ({ + logError: jest.fn(), +})); + +const jwt = require("jsonwebtoken"); +const Session = require("../models/session.model"); +const auth = require("../middleware/auth"); + +function createResponse() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +describe("auth middleware", () => { + const originalJwtSecret = process.env.JWT_SECRET; + + beforeEach(() => { + process.env.JWT_SECRET = "test-secret"; + jest.clearAllMocks(); + }); + + afterAll(() => { + if (originalJwtSecret === undefined) { + delete process.env.JWT_SECRET; + } else { + process.env.JWT_SECRET = originalJwtSecret; + } + }); + + test("uses a valid bearer token without reading the session cookie", async () => { + jwt.verify.mockReturnValue({ id: 5, role: "admin" }); + + const req = { + headers: { + authorization: "Bearer valid-token", + cookie: "sid=session-id", + }, + }; + const res = createResponse(); + const next = jest.fn(); + + await auth(req, res, next); + + expect(jwt.verify).toHaveBeenCalledWith("valid-token", "test-secret"); + expect(Session.getActiveSessionWithUser).not.toHaveBeenCalled(); + expect(req.user).toEqual({ id: 5, role: "admin" }); + expect(next).toHaveBeenCalled(); + }); + + test("falls back to a valid session cookie when the bearer token is stale", async () => { + jwt.verify.mockImplementation(() => { + throw new Error("stale token"); + }); + Session.getActiveSessionWithUser.mockResolvedValue({ + id: "session-id", + user_id: 7, + role: "user", + username: "shopper", + }); + + const req = { + headers: { + authorization: "Bearer stale-token", + cookie: "sid=session-id", + }, + }; + const res = createResponse(); + const next = jest.fn(); + + await auth(req, res, next); + + expect(Session.getActiveSessionWithUser).toHaveBeenCalledWith("session-id"); + expect(req.user).toEqual({ + id: 7, + role: "user", + username: "shopper", + }); + expect(req.session_id).toBe("session-id"); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test("rejects a stale bearer token when no session cookie is present", async () => { + jwt.verify.mockImplementation(() => { + throw new Error("stale token"); + }); + + const req = { + headers: { + authorization: "Bearer stale-token", + }, + }; + const res = createResponse(); + const next = jest.fn(); + + await auth(req, res, next); + + expect(Session.getActiveSessionWithUser).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: "unauthorized", + message: "Invalid or expired token", + }, + }); + }); +});