Phases 2-4: Auth, providers, stream management
This commit is contained in:
136
src/routes/providers/twitch.ts
Normal file
136
src/routes/providers/twitch.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user