mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
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:
parent
f8d652062b
commit
dcdba9e4e5
3 changed files with 219 additions and 1 deletions
|
|
@ -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.',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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> => {
|
||||
|
|
|
|||
154
packages/super-sync-server/tests/api.routes.spec.ts
Normal file
154
packages/super-sync-server/tests/api.routes.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue