fix: retain revocations for tokens without expiry
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user