feat(auth): add /replace-token endpoint for token revocation

Add an authenticated endpoint that allows users to replace their JWT
with a new one, invalidating all existing tokens. This is useful when:
- A token was accidentally shared or exposed
- User wants to log out all other sessions
- Security precaution after suspicious activity

Implementation:
- POST /api/replace-token requires valid authentication
- Increments token_version in database to invalidate old tokens
- Returns fresh JWT and user info
- Rate limited to 5 requests per 15 minutes

Includes comprehensive test coverage for the new endpoint.
This commit is contained in:
Johannes Millan 2025-12-06 11:20:09 +01:00
parent f8d652062b
commit dcdba9e4e5
3 changed files with 219 additions and 1 deletions

View file

@ -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<void> => {
}
},
);
// 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.',
});
}
},
);
};

View file

@ -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> => {

View file

@ -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');
});
});