diff --git a/packages/super-sync-server/src/api.ts b/packages/super-sync-server/src/api.ts index 6d7b307e43..38ec7ae6bc 100644 --- a/packages/super-sync-server/src/api.ts +++ b/packages/super-sync-server/src/api.ts @@ -1,6 +1,7 @@ import { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { registerUser, loginUser, verifyEmail } from './auth'; +import { registerUser, loginUser, verifyEmail, replaceToken } from './auth'; +import { authenticate, getAuthUser } from './middleware'; import { Logger } from './logger'; // Zod Schemas @@ -145,4 +146,32 @@ export const apiRoutes = async (fastify: FastifyInstance): Promise => { } }, ); + + // Replace JWT token (requires authentication) + // Use this when a token was accidentally shared or compromised + fastify.post( + '/replace-token', + { + preHandler: authenticate, + config: { + rateLimit: { + max: 5, + timeWindow: '15 minutes', + }, + }, + }, + async (req, reply) => { + try { + const user = getAuthUser(req); + const result = replaceToken(user.userId, user.email); + return reply.send(result); + } catch (err) { + const errMsg = err instanceof Error ? err.message : 'Unknown error'; + Logger.error(`Token replacement error: ${errMsg}`); + return reply.status(500).send({ + error: 'Failed to replace token. Please try again.', + }); + } + }, + ); }; diff --git a/packages/super-sync-server/src/auth.ts b/packages/super-sync-server/src/auth.ts index 7afcb59b73..c99dc25cf7 100644 --- a/packages/super-sync-server/src/auth.ts +++ b/packages/super-sync-server/src/auth.ts @@ -269,6 +269,41 @@ export const revokeAllTokens = (userId: number): void => { Logger.info(`All tokens revoked for user ${userId}`); }; +/** + * Replace the current JWT with a new one. + * This invalidates all existing tokens (including the current one) and returns a fresh token. + * Use this when a token was accidentally shared or compromised. + */ +export const replaceToken = ( + userId: number, + email: string, +): { token: string; user: { id: number; email: string } } => { + const db = getDb(); + + // Increment token version to invalidate all existing tokens + db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?').run( + userId, + ); + + // Get the new token version + const user = db.prepare('SELECT token_version FROM users WHERE id = ?').get(userId) as + | { token_version: number } + | undefined; + + if (!user) { + throw new Error('User not found'); + } + + const newTokenVersion = user.token_version; + const token = jwt.sign({ userId, email, tokenVersion: newTokenVersion }, JWT_SECRET, { + expiresIn: JWT_EXPIRY, + }); + + Logger.info(`Token replaced for user ${userId} (new version: ${newTokenVersion})`); + + return { token, user: { id: userId, email } }; +}; + export const verifyToken = async ( token: string, ): Promise<{ userId: number; email: string } | null> => { diff --git a/packages/super-sync-server/tests/api.routes.spec.ts b/packages/super-sync-server/tests/api.routes.spec.ts new file mode 100644 index 0000000000..8e19fd9ad1 --- /dev/null +++ b/packages/super-sync-server/tests/api.routes.spec.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Fastify, { FastifyInstance } from 'fastify'; +import { initDb, getDb } from '../src/db'; +import { apiRoutes } from '../src/api'; +import * as jwt from 'jsonwebtoken'; + +const JWT_SECRET = 'super-sync-dev-secret-do-not-use-in-production'; + +const createToken = (userId: number, email: string, tokenVersion: number = 0): string => { + return jwt.sign({ userId, email, tokenVersion }, JWT_SECRET, { expiresIn: '7d' }); +}; + +describe('API Routes - Replace Token', () => { + let app: FastifyInstance; + const userId = 1; + const email = 'test@test.com'; + + beforeEach(async () => { + initDb('./data', true); + const db = getDb(); + + db.prepare( + `INSERT INTO users (id, email, password_hash, is_verified, token_version, created_at) + VALUES (?, ?, 'hash', 1, 0, ?)`, + ).run(userId, email, Date.now()); + + app = Fastify(); + await app.register(apiRoutes, { prefix: '/api' }); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + }); + + it('should replace token and return a new one', async () => { + const oldToken = createToken(userId, email, 0); + + const response = await app.inject({ + method: 'POST', + url: '/api/replace-token', + headers: { + authorization: `Bearer ${oldToken}`, + }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.token).toBeDefined(); + expect(body.token).not.toBe(oldToken); + expect(body.user.id).toBe(userId); + expect(body.user.email).toBe(email); + }); + + it('should increment token_version in database', async () => { + const oldToken = createToken(userId, email, 0); + + await app.inject({ + method: 'POST', + url: '/api/replace-token', + headers: { + authorization: `Bearer ${oldToken}`, + }, + }); + + const db = getDb(); + const user = db + .prepare('SELECT token_version FROM users WHERE id = ?') + .get(userId) as { + token_version: number; + }; + expect(user.token_version).toBe(1); + }); + + it('should invalidate old token after replacement', async () => { + const oldToken = createToken(userId, email, 0); + + // Replace the token + const replaceResponse = await app.inject({ + method: 'POST', + url: '/api/replace-token', + headers: { + authorization: `Bearer ${oldToken}`, + }, + }); + + expect(replaceResponse.statusCode).toBe(200); + const newToken = replaceResponse.json().token; + + // Try to use the old token - should fail + const oldTokenResponse = await app.inject({ + method: 'POST', + url: '/api/replace-token', + headers: { + authorization: `Bearer ${oldToken}`, + }, + }); + + expect(oldTokenResponse.statusCode).toBe(401); + + // New token should work + const newTokenResponse = await app.inject({ + method: 'POST', + url: '/api/replace-token', + headers: { + authorization: `Bearer ${newToken}`, + }, + }); + + expect(newTokenResponse.statusCode).toBe(200); + }); + + it('should return 401 without authorization header', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/replace-token', + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error).toBe('Missing or invalid Authorization header'); + }); + + it('should return 401 with invalid token', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/replace-token', + headers: { + authorization: 'Bearer invalid-token', + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error).toBe('Invalid token'); + }); + + it('should return 401 with expired token version', async () => { + // Token with version 0, but we'll increment the DB version first + const db = getDb(); + db.prepare('UPDATE users SET token_version = 5 WHERE id = ?').run(userId); + + const oldVersionToken = createToken(userId, email, 0); + + const response = await app.inject({ + method: 'POST', + url: '/api/replace-token', + headers: { + authorization: `Bearer ${oldVersionToken}`, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json().error).toBe('Invalid token'); + }); +});