Add session revocation & consolidate rate limiting

Add sessions_invalidated_at to users (migration + Prisma schema) to support immediate session revocation. Set sessionsInvalidatedAt when an admin revokes a user's login token and enforce revocation checks in auth middleware and the refresh endpoint (compare token iat against sessionsInvalidatedAt). Add optional iat fields to JWT payload types. Scrub token from browser history after token-login. Consolidate rate-limiting logic into src/lib/utils/rateLimit.ts (rename/merge previous auth/apiToken rate limiter implementations), remove the old apiTokenRateLimit.ts, and update imports and tests to use the new module.
This commit is contained in:
kikootwo
2026-03-13 12:41:07 -04:00
parent c8bfcdb611
commit 4322c3af90
17 changed files with 68 additions and 105 deletions
+14
View File
@@ -172,6 +172,7 @@ export async function requireAuth(
select: {
id: true,
deletedAt: true,
sessionsInvalidatedAt: true,
},
});
@@ -186,6 +187,19 @@ export async function requireAuth(
);
}
// Check if session was invalidated after this token was issued
if (user.sessionsInvalidatedAt && payload.iat &&
payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) {
logger.warn('Token issued before session invalidation', { userId: payload.sub });
return NextResponse.json(
{
error: 'Unauthorized',
message: 'Session has been revoked',
},
{ status: 401 }
);
}
// Add user to request
const authenticatedRequest = request as AuthenticatedRequest;
authenticatedRequest.user = {
-92
View File
@@ -1,92 +0,0 @@
/**
* Component: API Token Rate Limiting
* Documentation: documentation/backend/services/api-tokens.md
*
* In-memory sliding-window rate limiter with lazy eviction and periodic sweep
* to prevent unbounded memory growth.
*/
type Bucket = {
count: number;
resetAt: number;
};
type RateLimitResult = {
allowed: boolean;
retryAfterSeconds: number;
};
const buckets = new Map<string, Bucket>();
/** Number of checkRateLimit calls since the last full sweep */
let checkCount = 0;
/** How often (in calls) to perform a full sweep of expired buckets */
const SWEEP_INTERVAL = 100;
/**
* Sweep the entire bucket map and delete all expired entries.
* Called automatically every SWEEP_INTERVAL checks.
*/
function sweepExpiredBuckets(): void {
const now = Date.now();
for (const [key, bucket] of buckets) {
if (now >= bucket.resetAt) {
buckets.delete(key);
}
}
}
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
const now = Date.now();
// Periodic full sweep every SWEEP_INTERVAL calls
checkCount += 1;
if (checkCount >= SWEEP_INTERVAL) {
checkCount = 0;
sweepExpiredBuckets();
}
const current = buckets.get(key);
// Lazy eviction: if the bucket is expired, delete it and start fresh
if (!current || now >= current.resetAt) {
if (current) {
buckets.delete(key);
}
buckets.set(key, { count: 1, resetAt: now + windowMs });
return { allowed: true, retryAfterSeconds: Math.ceil(windowMs / 1000) };
}
if (current.count >= maxRequests) {
return {
allowed: false,
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
};
}
current.count += 1;
return {
allowed: true,
retryAfterSeconds: Math.max(1, Math.ceil((current.resetAt - now) / 1000)),
};
}
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
}
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
}
/** Reset all buckets and the sweep counter. For testing only. */
export function _resetBuckets(): void {
buckets.clear();
checkCount = 0;
}
/** Get the current number of tracked buckets. For testing only. */
export function _getBucketCount(): number {
return buckets.size;
}
+2
View File
@@ -20,11 +20,13 @@ export interface TokenPayload {
plexId: string;
username: string;
role: string;
iat?: number; // Issued-at (auto-set by jsonwebtoken)
}
export interface RefreshTokenPayload {
sub: string;
type: 'refresh';
iat?: number; // Issued-at (auto-set by jsonwebtoken)
}
/**
@@ -1,5 +1,5 @@
/**
* Component: Auth Rate Limiting
* Component: Rate Limiting
* Documentation: documentation/backend/services/auth.md
*
* In-memory fixed-window rate limiter with lazy eviction and periodic sweep
@@ -11,7 +11,7 @@ type Bucket = {
resetAt: number;
};
type RateLimitResult = {
export type RateLimitResult = {
allowed: boolean;
retryAfterSeconds: number;
};
@@ -37,7 +37,7 @@ function sweepExpiredBuckets(): void {
}
}
function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
export function checkRateLimit(key: string, maxRequests: number, windowMs: number): RateLimitResult {
const now = Date.now();
// Periodic full sweep every SWEEP_INTERVAL calls
@@ -72,6 +72,16 @@ function checkRateLimit(key: string, maxRequests: number, windowMs: number): Rat
};
}
/** 10 attempts per minute per actor */
export function checkApiTokenCreateRateLimit(actorId: string): RateLimitResult {
return checkRateLimit(`api-token-create:${actorId}`, 10, 60 * 1000);
}
/** 20 attempts per minute per actor */
export function checkApiTokenRevokeRateLimit(actorId: string): RateLimitResult {
return checkRateLimit(`api-token-revoke:${actorId}`, 20, 60 * 1000);
}
/** 10 attempts per 15 minutes per IP */
export function checkTokenLoginRateLimit(ip: string): RateLimitResult {
return checkRateLimit(`token-login:${ip}`, 10, 15 * 60 * 1000);