diff --git a/prisma/migrations/20260313000000_add_sessions_invalidated_at/migration.sql b/prisma/migrations/20260313000000_add_sessions_invalidated_at/migration.sql new file mode 100644 index 0000000..17ecbe0 --- /dev/null +++ b/prisma/migrations/20260313000000_add_sessions_invalidated_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable - Add sessions_invalidated_at column for immediate session revocation +ALTER TABLE "users" ADD COLUMN "sessions_invalidated_at" TIMESTAMPTZ; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a613d62..c3ee742 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -60,6 +60,9 @@ model User { // Login token (admin-generated, for direct URL login) loginTokenHash String? @map("login_token_hash") // SHA-256 hash of the login token (never store plaintext) + // Session invalidation (set when login token is revoked to force-logout active sessions) + sessionsInvalidatedAt DateTime? @map("sessions_invalidated_at") + // Soft delete support deletedAt DateTime? @map("deleted_at") deletedBy String? @map("deleted_by") // Admin user ID who deleted this user diff --git a/src/app/api/admin/api-tokens/[id]/route.ts b/src/app/api/admin/api-tokens/[id]/route.ts index e0b0e7a..f55f34c 100644 --- a/src/app/api/admin/api-tokens/[id]/route.ts +++ b/src/app/api/admin/api-tokens/[id]/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; -import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit'; +import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit'; const logger = RMABLogger.create('API.Admin.ApiTokens'); diff --git a/src/app/api/admin/api-tokens/route.ts b/src/app/api/admin/api-tokens/route.ts index 4dd11e7..99a6b9d 100644 --- a/src/app/api/admin/api-tokens/route.ts +++ b/src/app/api/admin/api-tokens/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; -import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit'; +import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit'; import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens'; import { generateApiToken } from '@/lib/utils/api-token'; import { z } from 'zod'; diff --git a/src/app/api/admin/users/[id]/login-token/route.ts b/src/app/api/admin/users/[id]/login-token/route.ts index 05fb69d..c1b85f0 100644 --- a/src/app/api/admin/users/[id]/login-token/route.ts +++ b/src/app/api/admin/users/[id]/login-token/route.ts @@ -79,7 +79,7 @@ export async function DELETE( await prisma.user.update({ where: { id }, - data: { loginTokenHash: null }, + data: { loginTokenHash: null, sessionsInvalidatedAt: new Date() }, }); logger.info('Admin revoked login token for user', { diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts index f5875c0..f593c7b 100644 --- a/src/app/api/auth/refresh/route.ts +++ b/src/app/api/auth/refresh/route.ts @@ -45,9 +45,17 @@ export async function POST(request: NextRequest) { // Get user from database const user = await prisma.user.findUnique({ where: { id: payload.sub }, + select: { + id: true, + plexId: true, + plexUsername: true, + role: true, + deletedAt: true, + sessionsInvalidatedAt: true, + }, }); - if (!user) { + if (!user || user.deletedAt) { return NextResponse.json( { error: 'Unauthorized', @@ -57,6 +65,19 @@ export async function POST(request: NextRequest) { ); } + // Check if session was invalidated after this refresh token was issued + if (user.sessionsInvalidatedAt && payload.iat && + payload.iat < Math.floor(user.sessionsInvalidatedAt.getTime() / 1000)) { + logger.warn('Refresh token issued before session invalidation', { userId: payload.sub }); + return NextResponse.json( + { + error: 'Unauthorized', + message: 'Session has been revoked', + }, + { status: 401 } + ); + } + // Generate new access token const accessToken = generateAccessToken({ sub: user.id, diff --git a/src/app/api/auth/token/login/route.ts b/src/app/api/auth/token/login/route.ts index f5f7c42..ea1591e 100644 --- a/src/app/api/auth/token/login/route.ts +++ b/src/app/api/auth/token/login/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { prisma } from '@/lib/db'; import { generateAccessToken, generateRefreshToken } from '@/lib/utils/jwt'; import { RMABLogger } from '@/lib/utils/logger'; -import { checkTokenLoginRateLimit } from '@/lib/utils/authRateLimit'; +import { checkTokenLoginRateLimit } from '@/lib/utils/rateLimit'; import crypto from 'crypto'; const logger = RMABLogger.create('API.Auth.TokenLogin'); diff --git a/src/app/api/user/api-tokens/[id]/route.ts b/src/app/api/user/api-tokens/[id]/route.ts index 4169218..4e7fcc4 100644 --- a/src/app/api/user/api-tokens/[id]/route.ts +++ b/src/app/api/user/api-tokens/[id]/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; -import { checkApiTokenRevokeRateLimit } from '@/lib/utils/apiTokenRateLimit'; +import { checkApiTokenRevokeRateLimit } from '@/lib/utils/rateLimit'; const logger = RMABLogger.create('API.User.ApiTokens'); diff --git a/src/app/api/user/api-tokens/route.ts b/src/app/api/user/api-tokens/route.ts index f3902e4..51fe1d2 100644 --- a/src/app/api/user/api-tokens/route.ts +++ b/src/app/api/user/api-tokens/route.ts @@ -7,7 +7,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAuth, AuthenticatedRequest } from '@/lib/middleware/auth'; import { prisma } from '@/lib/db'; import { RMABLogger } from '@/lib/utils/logger'; -import { checkApiTokenCreateRateLimit } from '@/lib/utils/apiTokenRateLimit'; +import { checkApiTokenCreateRateLimit } from '@/lib/utils/rateLimit'; import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens'; import { generateApiToken } from '@/lib/utils/api-token'; import { z } from 'zod'; diff --git a/src/app/auth/token/login/page.tsx b/src/app/auth/token/login/page.tsx index 9b57aad..bf8b91f 100644 --- a/src/app/auth/token/login/page.tsx +++ b/src/app/auth/token/login/page.tsx @@ -22,6 +22,9 @@ function TokenLoginContent() { return; } + // Scrub token from browser URL/history immediately after extraction + window.history.replaceState({}, '', '/auth/token/login'); + fetch('/api/auth/token/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/lib/middleware/auth.ts b/src/lib/middleware/auth.ts index 7f6efd5..8ab4410 100644 --- a/src/lib/middleware/auth.ts +++ b/src/lib/middleware/auth.ts @@ -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 = { diff --git a/src/lib/utils/apiTokenRateLimit.ts b/src/lib/utils/apiTokenRateLimit.ts deleted file mode 100644 index 86b11fc..0000000 --- a/src/lib/utils/apiTokenRateLimit.ts +++ /dev/null @@ -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(); - -/** 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; -} diff --git a/src/lib/utils/jwt.ts b/src/lib/utils/jwt.ts index 657423e..26078d0 100644 --- a/src/lib/utils/jwt.ts +++ b/src/lib/utils/jwt.ts @@ -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) } /** diff --git a/src/lib/utils/authRateLimit.ts b/src/lib/utils/rateLimit.ts similarity index 79% rename from src/lib/utils/authRateLimit.ts rename to src/lib/utils/rateLimit.ts index 0d0a56c..ff65c68 100644 --- a/src/lib/utils/authRateLimit.ts +++ b/src/lib/utils/rateLimit.ts @@ -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); diff --git a/tests/api/admin-api-tokens.routes.test.ts b/tests/api/admin-api-tokens.routes.test.ts index c7865b8..87ff7c6 100644 --- a/tests/api/admin-api-tokens.routes.test.ts +++ b/tests/api/admin-api-tokens.routes.test.ts @@ -29,7 +29,7 @@ vi.mock('@/lib/middleware/auth', () => ({ requireAdmin: requireAdminMock, })); -vi.mock('@/lib/utils/apiTokenRateLimit', () => ({ +vi.mock('@/lib/utils/rateLimit', () => ({ checkApiTokenCreateRateLimit: checkApiTokenCreateRateLimitMock, })); diff --git a/tests/api/auth-login-token.routes.test.ts b/tests/api/auth-login-token.routes.test.ts index 12015a7..e5bf3df 100644 --- a/tests/api/auth-login-token.routes.test.ts +++ b/tests/api/auth-login-token.routes.test.ts @@ -20,7 +20,7 @@ vi.mock('@/lib/utils/jwt', () => ({ generateRefreshToken: generateRefreshTokenMock, })); -vi.mock('@/lib/utils/authRateLimit', () => ({ +vi.mock('@/lib/utils/rateLimit', () => ({ checkTokenLoginRateLimit: checkTokenLoginRateLimitMock, })); diff --git a/tests/utils/apiTokenRateLimit.test.ts b/tests/utils/apiTokenRateLimit.test.ts index 2c60583..49631da 100644 --- a/tests/utils/apiTokenRateLimit.test.ts +++ b/tests/utils/apiTokenRateLimit.test.ts @@ -9,7 +9,7 @@ import { checkApiTokenRevokeRateLimit, _resetBuckets, _getBucketCount, -} from '@/lib/utils/apiTokenRateLimit'; +} from '@/lib/utils/rateLimit'; import { MAX_TOKENS_PER_USER } from '@/lib/constants/api-tokens'; describe('API Token Rate Limiting', () => {