diff --git a/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js b/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js index 55302bbb..92a9fb8f 100644 --- a/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js +++ b/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js @@ -38,6 +38,10 @@ jest.mock('../utils/tokenUtils', () => ({ getGalleryTokenFromRequest: jest.fn(), })); +jest.mock('../utils/tokenRevocation', () => ({ + isTokenRevoked: jest.fn().mockResolvedValue(false), +})); + jest.mock('../utils/dbCompat', () => ({ formatBoolean: (v) => (v ? 1 : 0), })); @@ -45,6 +49,7 @@ jest.mock('../utils/dbCompat', () => ({ const jwt = require('jsonwebtoken'); const { db } = require('../database/db'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); +const { isTokenRevoked } = require('../utils/tokenRevocation'); const { verifyGalleryAccess } = require('../middleware/gallery'); function makeRes() { @@ -93,6 +98,55 @@ beforeEach(() => { db.mockReset(); jwt.verify.mockReset(); getGalleryTokenFromRequest.mockReset(); + isTokenRevoked.mockReset(); + isTokenRevoked.mockResolvedValue(false); +}); + +// ---- revoked gallery token (GHSA-q7f7-gjx8-mf6h) ----------------------- + +describe('verifyGalleryAccess — revoked token', () => { + it('returns 401 TOKEN_REVOKED and never reaches the events query when revoked', async () => { + getGalleryTokenFromRequest.mockReturnValue('tkn'); + jwt.verify.mockReturnValue({ type: 'gallery', eventId: 42 }); + isTokenRevoked.mockResolvedValue(true); + + const req = makeReq(); + const res = makeRes(); + const next = jest.fn(); + await verifyGalleryAccess(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: 'TOKEN_REVOKED' }), + ); + expect(db).not.toHaveBeenCalled(); + }); + + it('proceeds normally when the token is not revoked', async () => { + getGalleryTokenFromRequest.mockReturnValue('tkn'); + jwt.verify.mockReturnValue({ type: 'gallery', eventId: 42 }); + isTokenRevoked.mockResolvedValue(false); + + const eventsChain = {}; + eventsChain.where = jest.fn().mockReturnValue(eventsChain); + eventsChain.select = jest.fn().mockReturnValue(eventsChain); + eventsChain.first = jest.fn().mockResolvedValue({ + id: 42, slug: 'test-event', is_active: true, is_archived: false, + }); + db.mockImplementationOnce(() => eventsChain); + + const req = makeReq(); + const res = makeRes(); + const next = jest.fn(); + await verifyGalleryAccess(req, res, next); + + expect(isTokenRevoked).toHaveBeenCalledWith( + expect.objectContaining({ type: 'gallery', eventId: 42 }), + ); + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); }); // ---- customer-minted JWT, assignment intact ---------------------------- diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index a1ee968a..dad803f0 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -2,6 +2,7 @@ const jwt = require('jsonwebtoken'); const { db, withRetry } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); +const { isTokenRevoked } = require('../utils/tokenRevocation'); const logger = require('../utils/logger'); // Check if the request carries a valid admin preview token (Feature 3) @@ -89,6 +90,14 @@ async function verifyGalleryAccess(req, res, next) { return res.status(403).json({ error: 'Invalid token type for gallery access' }); } + // Gallery logout writes to the revocation store (see routes/auth.js), + // but nothing on this path ever read it back (GHSA-q7f7-gjx8-mf6h) — a + // logged-out gallery JWT kept working until natural expiry. + if (await isTokenRevoked(decoded)) { + logger.warn('[verifyGalleryAccess] Revoked token used', { eventId: decoded.eventId }); + return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' }); + } + // If we have a slug in the URL params or from pre-middleware, verify it matches if (requestedSlug) { // Verify by slug and ensure it matches the token's event