Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions client/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { expect, test } from '@playwright/test';

import { createTestDocument, getDocumentCard, openDocumentMenu } from './utils';
import {
createTestDocument,
getDocumentCard,
gotoDashboard,
openDocumentMenu,
} from './utils';

test.describe('Dashboard', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app');

// Authenticated via storageState (see auth.setup.ts)
await expect(
page.getByRole('heading', { name: 'Dashboard' }),
).toBeVisible();
await gotoDashboard(page);
});

test('should display dashboard with document list', async ({ page }) => {
Expand Down
7 changes: 3 additions & 4 deletions client/e2e/logout.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { expect, test } from '@playwright/test';

import { gotoDashboard } from './utils';

// Runs as the final project (after all other authenticated specs) because
// logging out nulls the user's refresh token server-side, invalidating the
// shared session for every other test context.
test.describe('Logout', () => {
test('should log out and block access to the app', async ({ page }) => {
await page.goto('/app');
await expect(
page.getByRole('heading', { name: 'Dashboard' }),
).toBeVisible();
await gotoDashboard(page);

await page.getByRole('button', { name: /user menu/i }).click();
await page.getByRole('menuitem', { name: /logout/i }).click();
Expand Down
26 changes: 24 additions & 2 deletions client/e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,34 @@ export async function openDocumentMenu(page: Page, title: string) {
await expect(page.getByRole('menu')).toBeVisible();
}

/**
* Navigate to /app and ensure the dashboard is visible.
* Resilient to refresh-token rotation: if the shared storageState's
* refresh token was already rotated by a previous test (multi-device
* sessions now rotate on refresh), the first goto will land on /login.
* In that case, re-login via the UI and continue.
*/
export async function gotoDashboard(page: Page) {
await page.goto('/app');
const dashboard = page.getByRole('heading', { name: 'Dashboard' });
try {
await expect(dashboard).toBeVisible({ timeout: 2_000 });
} catch {
// Session from shared storageState was rotated — re-authenticate
await page.goto('/login');
await page.getByLabel(/email/i).fill('test@example.com');
await page.getByLabel(/password/i).fill('testpassword');
await page.getByRole('button', { name: /login|sign in/i }).click();
await expect(page).toHaveURL(/.*\/app/);
await expect(dashboard).toBeVisible();
}
}

/**
* Create a document end-to-end from the dashboard.
*/
export async function createTestDocument(page: Page, title: string) {
await page.goto('/app');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await gotoDashboard(page);

await page.getByLabel(/new document/i).click();

Expand Down
16 changes: 9 additions & 7 deletions client/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ export default defineConfig({

/* Configure projects for major browsers.
*
* Order matters: logging in overwrites the user's single stored refresh
* token (server-side), which invalidates every previously-issued session,
* and logging out nulls it entirely. The unauthenticated auth specs
* (which perform a real login) therefore run BEFORE the setup project saves
* the storageState, the authenticated specs run against that shared session,
* and the logout spec runs LAST. Token refresh itself does not rotate the
* stored token, so those tests can safely run in parallel. */
* Order matters: with multi-device sessions (fix #51) each login creates
* its own Session row, but refresh now rotates the presented token and
* invalidates the previous jti. The unauthenticated auth specs (which
* perform real logins) still run BEFORE the setup project saves the
* storageState, the authenticated specs run against that shared session,
* and the logout spec runs LAST. Because refresh rotates, sharing the
* same storageState across sequential tests would make the second test's
* refresh present a stale jti — `createTestDocument` now re-logs in
* transparently when that happens. */
projects: [
{
name: 'auth-specs',
Expand Down
23 changes: 23 additions & 0 deletions server/prisma/migrations/20260828010125_add_session/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL,
"jti" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "sessions_refreshToken_key" ON "sessions"("refreshToken");

-- CreateIndex
CREATE UNIQUE INDEX "sessions_jti_key" ON "sessions"("jti");

-- CreateIndex
CREATE INDEX "sessions_userId_idx" ON "sessions"("userId");

-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
14 changes: 14 additions & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,24 @@ model User {
Document Document[]
Collaborator Collaborator[]
CollaborationRequest CollaborationRequest[]
Session Session[]

@@map("users")
}

model Session {
id String @id @default(uuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
refreshToken String @unique
jti String @unique
expiresAt DateTime
createdAt DateTime @default(now())

@@index([userId])
@@map("sessions")
}

model Document {
id String @id @default(uuid())
title String
Expand Down
145 changes: 140 additions & 5 deletions server/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import bcrypt from 'bcryptjs';
import crypto from 'crypto';
import { Request, Response } from 'express';
import asyncErrorWrapper from 'express-async-handler';
import { StatusCodes } from 'http-status-codes';
Expand Down Expand Up @@ -117,6 +118,17 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) =
try {
const user = await prisma.user.findUnique({ where: { email } });

if (user && user.isActive === false) {
logger.warn('Login failed - user deactivated', {
action: 'LOGIN_USER_INACTIVE',
...clientInfo,
userId: user.id,
email,
});
res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid email or password' });
return;
}

if (!user) {
await bcrypt.compare(password, DUMMY_PASSWORD_HASH);

Expand Down Expand Up @@ -157,19 +169,30 @@ export const loginUser = asyncErrorWrapper(async (req: Request, res: Response) =
}
);

// Generate Access Token with a short expiration time
// Generate refresh token with jti for rotation / reuse detection
const jti = crypto.randomUUID();
const refreshToken = jwt.sign(
{
userId: user.id,
username: user.username,
jti,
},
process.env.JWT_REFRESH_SECRET,
{
expiresIn: '24h',
}
);

// update user with referesh token
// Create a new session for this device; do not overwrite other sessions
await prisma.session.create({
data: {
userId: user.id,
refreshToken,
jti,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
// Keep legacy column in sync for any external read (not used for auth)
await prisma.user.update({
where: { email },
data: { refreshToken },
Expand Down Expand Up @@ -236,6 +259,22 @@ export const logoutUser = asyncErrorWrapper(async (req: AuthenticatedRequest, re
}

try {
const presented = req.cookies?.refreshToken as string | undefined;
if (presented) {
try {
const dec = jwt.verify(presented, process.env.JWT_REFRESH_SECRET!) as jwt.JwtPayload & { jti?: string };
if (dec.jti) {
await prisma.session.deleteMany({ where: { jti: dec.jti, userId } });
} else {
await prisma.session.deleteMany({ where: { refreshToken: presented, userId } });
}
} catch {
await prisma.session.deleteMany({ where: { refreshToken: presented, userId } });
}
} else {
// Fallback: clear all sessions for user if no token presented (e.g. legacy)
await prisma.session.deleteMany({ where: { userId } });
}
await prisma.user.update({
where: { id: userId },
data: { refreshToken: null },
Expand Down Expand Up @@ -307,21 +346,109 @@ export const refreshToken = asyncErrorWrapper(async (req: Request, res: Response
return;
}
try {
const payloadWithJti = payload as jwt.JwtPayload & { jti?: string };
const user = await prisma.user.findUnique({ where: { id: payload.userId } });

if (!user || user.refreshToken !== refreshToken) {
if (!user) {
logger.warn('Token refresh failed - token mismatch or user not found', {
action: 'REFRESH_TOKEN_MISMATCH',
...clientInfo,
userId: payload.userId,
userExists: !!user,
tokenMatches: user?.refreshToken === refreshToken,
userExists: false,
tokenMatches: false,
});
res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid refresh token' });
return;
}

// isActive enforcement — deactivated accounts cannot refresh
if (user.isActive === false) {
logger.warn('Token refresh failed - user deactivated', {
action: 'REFRESH_TOKEN_USER_INACTIVE',
...clientInfo,
userId: user.id,
});
res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid refresh token' });
return;
}

// Prefer Session lookup by jti (new tokens); fallback to legacy column for old tokens
let session: Awaited<ReturnType<typeof prisma.session.findUnique>> | null = null;
if (payloadWithJti.jti) {
session = await prisma.session.findUnique({ where: { jti: payloadWithJti.jti } });

// Reuse detection: valid JWT for user but no matching session → token was already rotated/revoked
if (!session || session.refreshToken !== refreshToken || session.userId !== payload.userId) {
logger.warn('Token refresh failed - token reuse detected', {
action: 'REFRESH_TOKEN_REUSE',
...clientInfo,
userId: payload.userId,
jti: payloadWithJti.jti,
});
res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid refresh token' });
return;
}

if (session.expiresAt < new Date()) {
logger.warn('Token refresh failed - session expired', {
action: 'REFRESH_TOKEN_EXPIRED',
...clientInfo,
userId: payload.userId,
});
await prisma.session.delete({ where: { id: session.id } });
res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid refresh token' });
return;
}
} else {
// Legacy token without jti — fall back to single-column check
if (user.refreshToken !== refreshToken) {
logger.warn('Token refresh failed - token mismatch or user not found', {
action: 'REFRESH_TOKEN_MISMATCH',
...clientInfo,
userId: payload.userId,
userExists: true,
tokenMatches: false,
});
res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid refresh token' });
return;
}
// Migrate legacy: create a session for this token so future rotates work
session = await prisma.session.create({
data: {
userId: user.id,
refreshToken,
jti: crypto.randomUUID(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
}

// Rotate: new jti + new refresh token, update same session row
const newJti = crypto.randomUUID();
const newRefreshToken = jwt.sign(
{
userId: user.id,
username: user.username,
jti: newJti,
},
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: '24h' }
);

await prisma.session.update({
where: { id: session.id },
data: {
refreshToken: newRefreshToken,
jti: newJti,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
// Keep legacy column in sync (not used for auth, but for observability)
await prisma.user.update({
where: { id: user.id },
data: { refreshToken: newRefreshToken },
});

const newAccessToken = jwt.sign(
{
userId: user.id,
Expand All @@ -338,6 +465,14 @@ export const refreshToken = asyncErrorWrapper(async (req: Request, res: Response
username: user.username,
});

const isProduction = process.env.NODE_ENV === 'production';
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000,
sameSite: isProduction ? 'none' : 'lax',
secure: isProduction,
});

res.status(StatusCodes.OK).json({
accessToken: newAccessToken,
user: {
Expand Down
14 changes: 12 additions & 2 deletions server/src/middlewares/auth.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { StatusCodes } from 'http-status-codes';
import jwt from 'jsonwebtoken';
import { JwtPayload } from 'jsonwebtoken';

export const authenticate = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
import { prisma } from '@/lib/prisma';

export const authenticate = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
Expand All @@ -13,7 +15,15 @@ export const authenticate = (req: AuthenticatedRequest, res: Response, next: Nex
const token = authHeader.split(' ')[1];

try {
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET!) as JwtPayload;
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET!) as JwtPayload & { userId: string };
// Enforce isActive so deactivated accounts lose access even with a valid JWT (15m window)
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: { isActive: true },
});
if (user && user.isActive === false) {
return res.status(StatusCodes.UNAUTHORIZED).json({ error: 'Invalid or expired token' });
}
req.user = { userId: decoded.userId, username: decoded.username };
next();
} catch {
Expand Down
Loading
Loading