import { FastifyPluginAsync } from 'fastify'; import { z } from 'zod'; import { db } from '../config/database.js'; import { users } from '../db/schema.js'; import { eq } from 'drizzle-orm'; import { GiteaService } from '../services/gitea.service.js'; import { env } from '../config/env.js'; // Use explicit JSON schema for Fastify route validation to avoid provider issues const callbackQueryJsonSchema = { type: 'object', required: ['code', 'state'], properties: { code: { type: 'string' }, state: { type: 'string' }, }, } as const; const authRoute: FastifyPluginAsync = async (fastify) => { const giteaService = new GiteaService(); /** * GET /auth/gitea * Initiate OAuth flow */ fastify.get('/auth/gitea', async (request, reply) => { // Generate CSRF state token const state = giteaService.generateState(); // Store state in a short-lived cookie reply.setCookie('oauth_state', state, { path: '/', httpOnly: true, sameSite: 'lax', // Use HTTPS-based detection to avoid setting Secure on localhost HTTP secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'), maxAge: 10 * 60, // 10 minutes }); // Generate authorization URL const authUrl = giteaService.getAuthorizationUrl(state); // Redirect to Gitea return reply.redirect(authUrl); }); /** * GET /auth/callback * OAuth callback endpoint */ fastify.get('/auth/callback', { schema: { querystring: callbackQueryJsonSchema, }, }, async (request, reply) => { try { const { code, state } = request.query as { code: string; state: string }; // Verify CSRF state from cookie const expectedState = request.cookies?.oauth_state as string | undefined; if (!expectedState || state !== expectedState) { return reply.code(400).send({ error: 'Invalid state parameter' }); } // Clear state cookie reply.clearCookie('oauth_state', { path: '/' }); // Exchange code for access token const tokenResponse = await giteaService.exchangeCodeForToken(code); // Fetch user info from Gitea const giteaUser = await giteaService.getUserInfo(tokenResponse.access_token); // Check if user is allowed if (!giteaService.isUserAllowed(giteaUser.login)) { return reply.code(403).send({ error: 'Access denied. You are not authorized to access this CMS.' }); } // Find or create user in database let [user] = await db .select() .from(users) .where(eq(users.giteaId, giteaUser.id.toString())) .limit(1); if (!user) { // Create new user [user] = await db.insert(users).values({ giteaId: giteaUser.id.toString(), giteaUsername: giteaUser.login, giteaEmail: giteaUser.email, displayName: giteaUser.full_name, avatarUrl: giteaUser.avatar_url, lastLogin: new Date(), }).returning(); } else { // Update existing user [user] = await db .update(users) .set({ giteaUsername: giteaUser.login, giteaEmail: giteaUser.email, displayName: giteaUser.full_name, avatarUrl: giteaUser.avatar_url, lastLogin: new Date(), }) .where(eq(users.id, user.id)) .returning(); } // Generate JWT for session management const token = fastify.jwt.sign( { id: user.id, giteaId: user.giteaId, username: user.giteaUsername || '', role: user.role ?? 'admin', }, { expiresIn: '24h' } ); // Also set token as HttpOnly cookie so subsequent API calls authenticate reliably reply.setCookie('token', token, { path: '/', httpOnly: true, sameSite: 'lax', secure: !!env.FRONTEND_URL && env.FRONTEND_URL.startsWith('https'), maxAge: 60 * 60 * 24, // 24h }); // Redirect to admin dashboard const frontendUrl = env.FRONTEND_URL; return reply.redirect(`${frontendUrl}/admin`); } catch (error) { fastify.log.error({ err: error }, 'OAuth callback error'); return reply.code(500).send({ error: 'Authentication failed' }); } }); /** * GET /auth/me * Get current user info */ fastify.get('/auth/me', { preHandler: [fastify.authenticate], }, async (request, reply) => { const userId = request.user.id; const [user] = await db .select() .from(users) .where(eq(users.id, userId)) .limit(1); if (!user) { return reply.code(404).send({ error: 'User not found' }); } return { user: { id: user.id, giteaUsername: user.giteaUsername, giteaEmail: user.giteaEmail, displayName: user.displayName, avatarUrl: user.avatarUrl, role: user.role, }, }; }); /** * POST /auth/logout * Logout (client-side token deletion) */ fastify.post('/auth/logout', { preHandler: [fastify.authenticate], }, async (request, reply) => { // For JWT, logout is primarily client-side (delete token) // You could maintain a token blacklist in Redis for production reply.clearCookie('token', { path: '/' }); return { message: 'Logged out successfully' }; }); }; export default authRoute;