fix: retain revocations for tokens without expiry

This commit is contained in:
Paul Nothaft
2026-09-08 15:54:01 +02:00
parent a31a2e25e2
commit 662516a5ad
9 changed files with 234 additions and 17 deletions
+1 -1
View File
@@ -421,7 +421,7 @@ async function initializeDatabase() {
table.integer('user_id').nullable(); // User who owned the token
table.string('token_type', 20); // admin, gallery, etc.
table.timestamp('revoked_at').defaultTo(db.fn.now());
table.timestamp('expires_at').notNullable(); // When token would have expired
table.timestamp('expires_at').nullable(); // NULL retains tokens without a known expiry
table.string('reason', 100); // password_change, logout, compromised, etc.
table.text('metadata'); // Additional JSON data
+4 -3
View File
@@ -182,16 +182,17 @@ router.post('/logout', adminAuth, handleAsync(async (req, res) => {
// old header-only read skipped revocation entirely for cookie-based logout,
// leaving the JWT valid until expiry while reporting a successful logout.
const token = req.token;
clearAdminAuthCookie(res);
if (token) {
// End the in-memory session AND revoke the JWT (GHSA-cjqh) — the token
// is otherwise valid until expiry, so photoAuth/adminAuth would keep
// honouring it after logout. isTokenRevoked() checks this store.
endSession(token);
const { revokeToken } = require('../utils/tokenRevocation');
await revokeToken(token, 'logout');
if (!await revokeToken(token, 'logout')) {
throw new Error('Token revocation failed');
}
}
// Clear the auth cookie so the browser stops sending the (now revoked) JWT.
clearAdminAuthCookie(res);
// Log activity
await logActivity('admin_logout',
+9 -2
View File
@@ -356,7 +356,9 @@ router.post('/logout', async (req, res) => {
if (token) {
// Revoke the token so it can't be reused, then end the session
await revokeToken(token, 'user_logout');
if (!await revokeToken(token, 'user_logout')) {
throw new Error('Token revocation failed');
}
endSession(token);
try {
@@ -400,6 +402,8 @@ router.post('/logout', async (req, res) => {
res.json({ message: 'Logged out successfully', ...(ssoLogoutUrl ? { ssoLogoutUrl } : {}) });
} catch (error) {
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
errorResponse(res, error, 500, 'Logout failed');
}
});
@@ -714,11 +718,14 @@ router.post('/gallery/logout', async (req, res) => {
const { slug } = req.body || {};
const token = getGalleryTokenFromRequest(req, slug);
if (token) {
await revokeToken(token, 'gallery_logout');
if (!await revokeToken(token, 'gallery_logout')) {
throw new Error('Token revocation failed');
}
}
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
clearGalleryAuthCookies(res, req.body?.slug);
errorResponse(res, error, 500, 'Logout failed');
}
});
+3 -1
View File
@@ -181,7 +181,9 @@ router.post('/logout', async (req, res) => {
try {
const token = getCustomerTokenFromRequest(req);
if (token) {
await revokeToken(token, 'user_logout');
if (!await revokeToken(token, 'user_logout')) {
throw new Error('Token revocation failed');
}
}
clearCustomerAuthCookie(res);
res.json({ message: 'Logged out successfully' });
+19 -9
View File
@@ -6,6 +6,7 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('./logger');
const MAX_SQL_EXPIRY_SECONDS = Date.parse('9999-12-31T23:59:59Z') / 1000;
/**
* Add a token to the revocation list
@@ -52,20 +53,28 @@ async function revokeToken(token, reason, metadata = {}) {
// undefined/string slip through and cause an INSERT type error.
const userIdNumeric = Number.isInteger(payload.id) ? payload.id : null;
// onConflict.ignore: revoking an already-revoked token is a no-op,
// not an error. Hits the unique (token_id) index when the same JWT
// is logged out twice (e.g. duplicate /logout from two tabs, or a
// session-expiry path that races with an explicit logout). The
// previous insert was authoritative; nothing to do.
await db('revoked_tokens').insert({
// JWT permits a missing exp. Keep that revocation permanently: an
// arbitrary fallback TTL would make the token usable again after cleanup.
// Retain unrepresentable expiries too, using the common SQL/ISO date range.
const expiresAt = Number.isFinite(payload.exp)
&& payload.exp >= 0 && payload.exp <= MAX_SQL_EXPIRY_SECONDS
? new Date(Math.ceil(payload.exp * 1000)).toISOString()
: null;
// Duplicate logouts are idempotent. A permanent revocation must also
// upgrade an existing expiring entry with the same legacy key or jti;
// logging out an expiring token must never shorten that retention again.
const insert = db('revoked_tokens').insert({
token_id: buildTokenId(payload),
user_id: userIdNumeric,
token_type: payload.type,
revoked_at: new Date().toISOString(),
expires_at: new Date(payload.exp * 1000).toISOString(),
expires_at: expiresAt,
reason,
metadata: JSON.stringify(metadata)
}).onConflict('token_id').ignore();
}).onConflict('token_id');
if (expiresAt === null) await insert.merge({ expires_at: null });
else await insert.ignore();
logger.info('Token revoked', {
userId: payload.id ?? payload.customerId ?? null,
@@ -131,6 +140,7 @@ async function revokeAllUserTokens(userId, reason) {
async function cleanupExpiredRevocations() {
try {
const deleted = await db('revoked_tokens')
.whereNotNull('expires_at')
.where('expires_at', '<', new Date().toISOString())
.delete();
@@ -156,4 +166,4 @@ module.exports = { buildTokenId,
revokeAllUserTokens,
cleanupExpiredRevocations,
initializeRevocationCleanup
};
};