137 lines
4.4 KiB
TypeScript
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;
|