import { FastifyPluginAsync } from 'fastify'; import { randomUUID } from 'node:crypto'; import { requireAuth } from '../../middleware/require-auth.js'; import { encrypt } from '../../services/crypto.service.js'; import { getTwitchAuthUrl, exchangeTwitchCode, fetchTwitchProfile, } from '../../services/twitch.service.js'; import { config } from '../../config.js'; import { AppError } from '../../plugins/error-handler.js'; import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js'; // In-memory CSRF state store (state → { userId, expiresAt }) const pendingStates = new Map(); setInterval(() => { const now = Date.now(); for (const [key, val] of pendingStates) { if (val.expiresAt < now) pendingStates.delete(key); } }, 5 * 60 * 1000); const twitchRoutes: FastifyPluginAsync = async (fastify) => { // GET /providers/twitch/auth-url — get OAuth URL with CSRF state fastify.get('/providers/twitch/auth-url', { preHandler: [requireAuth], }, async (request) => { const state = randomUUID(); pendingStates.set(state, { userId: request.userId, expiresAt: Date.now() + 10 * 60 * 1000, }); const response: AuthUrlResponse = { url: getTwitchAuthUrl(state), state, }; return response; }); // GET /providers/twitch/callback-redirect — Twitch redirects here → 302 to app deep link fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>( '/providers/twitch/callback-redirect', async (request, reply) => { const { code, state, error } = request.query; if (error || !code || !state) { reply.status(302).redirect( `${config.appScheme}://twitch/callback?error=${error || 'missing_params'}`, ); return; } reply.status(302).redirect( `${config.appScheme}://twitch/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`, ); }, ); // POST /providers/twitch/callback — app sends code+state, backend exchanges fastify.post<{ Body: ProviderCallbackBody }>('/providers/twitch/callback', { preHandler: [requireAuth], schema: { body: { type: 'object', required: ['code', 'state'], properties: { code: { type: 'string', minLength: 1 }, state: { type: 'string', minLength: 1 }, }, additionalProperties: false, }, }, }, async (request) => { const { code, state } = request.body; // Validate CSRF state const pending = pendingStates.get(state); if (!pending || pending.userId !== request.userId || pending.expiresAt < Date.now()) { pendingStates.delete(state); throw new AppError(400, 'Invalid or expired state parameter'); } pendingStates.delete(state); // Exchange code for tokens const tokens = await exchangeTwitchCode(code); const profile = await fetchTwitchProfile(tokens.accessToken); // Encrypt tokens const accessEnc = encrypt(tokens.accessToken); const refreshEnc = encrypt(tokens.refreshToken); // Upsert linked account const account = await fastify.prisma.linkedAccount.upsert({ where: { userId_serviceId: { userId: request.userId, serviceId: 'TWITCH', }, }, update: { displayName: profile.displayName, accountId: profile.accountId, avatarUrl: profile.avatarUrl, accessTokenEnc: accessEnc.ciphertext, refreshTokenEnc: refreshEnc.ciphertext, accessTokenIv: accessEnc.iv, refreshTokenIv: refreshEnc.iv, tokenExpiresAt: new Date(Date.now() + tokens.expiresIn * 1000), }, create: { userId: request.userId, serviceId: 'TWITCH', displayName: profile.displayName, accountId: profile.accountId, avatarUrl: profile.avatarUrl, accessTokenEnc: accessEnc.ciphertext, refreshTokenEnc: refreshEnc.ciphertext, accessTokenIv: accessEnc.iv, refreshTokenIv: refreshEnc.iv, tokenExpiresAt: new Date(Date.now() + tokens.expiresIn * 1000), }, }); const response: LinkedAccountResponse = { id: account.id, serviceId: account.serviceId, displayName: account.displayName, accountId: account.accountId, avatarUrl: account.avatarUrl, }; return response; }); }; export default twitchRoutes;