Files
picpeak/backend/src/utils/tokenRevocation.js
T
LucaandClaude Opus 4.6 087ef45942 feat(customers): customer portal (#354) on top of feature-flags reorg
Implements the recurring-customer login surface from
the-luap/picpeak#354 plugged into the maintainer's
new feature-flag infrastructure (PR #443) instead of
a parallel toggle.

* New `customerPortal` feature flag (foundation flag for the
  not-yet-built calendar/quotes/bills/messaging customer
  surfaces). Defaults FALSE on fresh installs, TRUE on existing
  installs (events > 0) via migration 095 so live customer
  accounts don't disappear mid-deployment.
* Foundation schema: customer_accounts, customer_invitations,
  event_customer_assignments, customer_password_resets, plus
  RBAC permissions customers.view / .create / .delete granted
  to super_admin + admin system roles.
* Backend: /api/admin/customers (invite, list, search, assign,
  deactivate, reset password) + /api/customer/auth/* +
  /api/customer/* (login, dashboard, accept-invite, reset).
  Customer JWT bypass minted via
  /api/customer/events/:slug/access-token so existing gallery
  middleware stays untouched.
* Frontend: /customer/* route tree gated by RequireFeature flag
  customerPortal, with login / dashboard / accept-invite /
  reset pages and a customer-side sidebar layout.
  /admin/customers and /admin/customers/:id gated identically.
* Settings → Features grows a "Customers" section with a
  Customer portal card. The maintainer's Features tab stays the
  single source of truth — no parallel Advanced features tab.
* CustomerAccountPicker on event create/edit forms hides itself
  when the flag is off; backend ignores customer_account_ids in
  that case instead of erroring the whole event save.

Translations: en + de hand-translated. nl/pt/ru fall through to
en — flagged here as needing native review.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-05-11 00:05:20 +02:00

153 lines
4.6 KiB
JavaScript

/**
* Token Revocation System
* Provides ability to invalidate tokens before expiration
*/
const { db } = require('../database/db');
const logger = require('./logger');
/**
* Add a token to the revocation list
* @param {string} token - JWT token to revoke
* @param {string} reason - Reason for revocation
* @param {Object} metadata - Additional metadata
*/
/**
* Resolve the per-token unique identifier used as the lookup key in
* revoked_tokens.token_id. Customer JWTs (#354) use `customerId` instead
* of `id`, so the original `${payload.id}-${payload.iat}` produced
* `undefined-…` keys for every customer token and silently collided
* across all customer logins. Falling back to customerId — and finally
* to a stable hash of the payload — keeps the key unique per token.
*/
function buildTokenId(payload) {
if (payload.jti) return payload.jti;
const subject = payload.id ?? payload.customerId ?? payload.guestId ?? payload.eventId ?? 'anon';
return `${subject}-${payload.iat}-${payload.type || 'unknown'}`;
}
async function revokeToken(token, reason, metadata = {}) {
try {
// Extract token info without full verification (it might be compromised)
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid token format');
}
// Decode payload
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
// user_id is integer-typed in revoked_tokens; for non-admin tokens
// we may not have an integer (customer) or any id at all (gallery
// tokens use eventId). Coerce to null instead of letting an
// 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({
token_id: buildTokenId(payload),
user_id: userIdNumeric,
token_type: payload.type,
revoked_at: new Date().toISOString(),
expires_at: new Date(payload.exp * 1000).toISOString(),
reason,
metadata: JSON.stringify(metadata)
}).onConflict('token_id').ignore();
logger.info('Token revoked', {
userId: payload.id ?? payload.customerId ?? null,
tokenType: payload.type,
reason
});
return true;
} catch (error) {
logger.error('Failed to revoke token', error);
return false;
}
}
/**
* Check if a token is revoked
* @param {Object} decodedToken - Decoded JWT payload
* @returns {boolean} - True if token is revoked
*/
async function isTokenRevoked(decodedToken) {
try {
const tokenId = buildTokenId(decodedToken);
const revoked = await db('revoked_tokens')
.where('token_id', tokenId)
.first();
return !!revoked;
} catch (error) {
logger.error('Failed to check token revocation', error);
// Fail closed - treat as revoked if we can't check
return true;
}
}
/**
* Revoke all tokens for a user
* @param {number} userId - User ID
* @param {string} reason - Reason for revocation
*/
async function revokeAllUserTokens(userId, reason) {
try {
// This effectively revokes all tokens by setting a revocation time
// Any token issued before this time will be considered revoked
await db('user_token_revocations').insert({
user_id: userId,
revoked_at: new Date().toISOString(),
reason
}).onConflict('user_id').merge();
logger.info('All user tokens revoked', { userId, reason });
return true;
} catch (error) {
logger.error('Failed to revoke user tokens', error);
return false;
}
}
/**
* Clean up expired revoked tokens
* Should be run periodically
*/
async function cleanupExpiredRevocations() {
try {
const deleted = await db('revoked_tokens')
.where('expires_at', '<', new Date().toISOString())
.delete();
if (deleted > 0) {
logger.info(`Cleaned up ${deleted} expired token revocations`);
}
} catch (error) {
logger.error('Failed to cleanup revoked tokens', error);
}
}
/**
* Initialize cleanup job for expired revocations
*/
function initializeRevocationCleanup() {
// Run cleanup every 6 hours
setInterval(cleanupExpiredRevocations, 6 * 60 * 60 * 1000);
// Run initial cleanup
cleanupExpiredRevocations();
}
module.exports = {
revokeToken,
isTokenRevoked,
revokeAllUserTokens,
cleanupExpiredRevocations,
initializeRevocationCleanup
};