Files
lck-control-backend/src/routes/providers/twitch.ts

137 lines
4.4 KiB
TypeScript

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<string, { userId: string; expiresAt: number }>();
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;