TypeScript · 4183 bytes Raw Blame History
1 import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
3 declare module 'fastify' {
4 interface FastifyRequest {
5 user?: {
6 userId: string;
7 username: string;
8 sessionId: string;
9 };
10 }
11
12 interface FastifyInstance {
13 authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
14 config: import('../config.js').Config;
15 }
16 }
17
18 export async function authMiddleware(fastify: FastifyInstance) {
19 // Register authentication hook
20 fastify.decorate('authenticate', async function (request: FastifyRequest, reply: FastifyReply) {
21 try {
22 // Extract token from Authorization header
23 const authHeader = request.headers.authorization;
24 if (!authHeader || !authHeader.startsWith('Bearer ')) {
25 throw fastify.httpErrors.unauthorized('Authorization header required');
26 }
27
28 const token = authHeader.substring(7); // Remove 'Bearer ' prefix
29
30 // Verify JWT token
31 const decoded = fastify.jwt.verify(token) as {
32 userId: string;
33 username: string;
34 sessionId: string;
35 };
36
37 // Attach user info to request
38 request.user = {
39 userId: decoded.userId,
40 username: decoded.username,
41 sessionId: decoded.sessionId,
42 };
43 } catch (error) {
44 if (error.code === 'FST_JWT_AUTHORIZATION_TOKEN_EXPIRED') {
45 throw fastify.httpErrors.unauthorized('Token expired');
46 } else if (error.code === 'FST_JWT_AUTHORIZATION_TOKEN_INVALID') {
47 throw fastify.httpErrors.unauthorized('Invalid token');
48 } else if (error.statusCode) {
49 throw error;
50 } else {
51 fastify.log.error(error, 'Authentication failed');
52 throw fastify.httpErrors.unauthorized('Authentication failed');
53 }
54 }
55 });
56
57 // Optional authentication hook (for endpoints that work with or without auth)
58 fastify.decorate('optionalAuth', async function (request: FastifyRequest, reply: FastifyReply) {
59 try {
60 const authHeader = request.headers.authorization;
61 if (authHeader && authHeader.startsWith('Bearer ')) {
62 const token = authHeader.substring(7);
63 const decoded = fastify.jwt.verify(token) as {
64 userId: string;
65 username: string;
66 sessionId: string;
67 };
68
69 request.user = {
70 userId: decoded.userId,
71 username: decoded.username,
72 sessionId: decoded.sessionId,
73 };
74 }
75 } catch (error) {
76 // Ignore authentication errors for optional auth
77 fastify.log.debug(error, 'Optional authentication failed');
78 }
79 });
80
81 // Rate limiting middleware (simple in-memory implementation)
82 const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
83
84 fastify.addHook('preHandler', async (request, reply) => {
85 // Skip rate limiting for health checks
86 if (request.url === '/api/health') {
87 return;
88 }
89
90 const clientIp = request.ip;
91 const now = Date.now();
92 const windowMs = 60 * 1000; // 1 minute
93 const maxRequests = 100; // 100 requests per minute
94
95 const key = `${clientIp}:${Math.floor(now / windowMs)}`;
96 const current = rateLimitStore.get(key) || { count: 0, resetTime: now + windowMs };
97
98 if (current.count >= maxRequests) {
99 reply.header('Retry-After', Math.ceil((current.resetTime - now) / 1000));
100 throw fastify.httpErrors.tooManyRequests('Rate limit exceeded');
101 }
102
103 current.count++;
104 rateLimitStore.set(key, current);
105
106 // Clean up old entries
107 if (Math.random() < 0.01) { // 1% chance to clean up
108 for (const [k, v] of rateLimitStore.entries()) {
109 if (v.resetTime < now) {
110 rateLimitStore.delete(k);
111 }
112 }
113 }
114 });
115
116 // Security headers
117 fastify.addHook('onSend', async (request, reply) => {
118 reply.header('X-Content-Type-Options', 'nosniff');
119 reply.header('X-Frame-Options', 'DENY');
120 reply.header('X-XSS-Protection', '1; mode=block');
121 reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
122
123 if (fastify.config.nodeEnv === 'production') {
124 reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
125 }
126 });
127 }