fix: fall back to session cookies for stale tokens

This commit is contained in:
Nico 2026-05-26 00:40:06 -07:00
parent 6b3d267abb
commit e793a59570
3 changed files with 139 additions and 20 deletions

View File

@ -8,27 +8,30 @@ const { logError } = require("../utils/logger");
async function auth(req, res, next) { async function auth(req, res, next) {
const header = req.headers.authorization || ""; const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null; const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
const cookies = parseCookieHeader(req.headers.cookie);
const sid = cookies[cookieName()];
if (token) { if (token) {
const jwtSecret = process.env.JWT_SECRET; const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) { if (!jwtSecret && !sid) {
logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured")); logError(req, "middleware.auth.jwtSecretMissing", new Error("JWT_SECRET is not configured"));
return sendError(res, 500, "Authentication is unavailable"); return sendError(res, 500, "Authentication is unavailable");
} }
if (jwtSecret) {
try { try {
const decoded = jwt.verify(token, jwtSecret); const decoded = jwt.verify(token, jwtSecret);
req.user = decoded; // id + role req.user = decoded; // id + role
return next(); return next();
} catch (err) { } catch (err) {
if (!sid) {
return sendError(res, 401, "Invalid or expired token"); return sendError(res, 401, "Invalid or expired token");
} }
} }
}
}
try { try {
const cookies = parseCookieHeader(req.headers.cookie);
const sid = cookies[cookieName()];
if (!sid) { if (!sid) {
return sendError(res, 401, "Missing authentication"); return sendError(res, 401, "Missing authentication");
} }

View File

@ -10,16 +10,14 @@ async function optionalAuth(req, res, next) {
if (token) { if (token) {
const jwtSecret = process.env.JWT_SECRET; const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) { if (jwtSecret) {
return next();
}
try { try {
const decoded = jwt.verify(token, jwtSecret); const decoded = jwt.verify(token, jwtSecret);
req.user = decoded; req.user = decoded;
return next(); return next();
} catch (err) { } catch (err) {
return next(); // Continue to the session cookie fallback below.
}
} }
} }

View File

@ -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",
},
});
});
});