Phases 2-4: Auth, providers, stream management
This commit is contained in:
37
src/services/crypto.service.ts
Normal file
37
src/services/crypto.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'node:crypto';
|
||||
import { config } from '../config.js';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12;
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
return Buffer.from(config.tokenEncryptionKey, 'hex');
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): { ciphertext: string; iv: string } {
|
||||
const key = getKey();
|
||||
const iv = randomBytes(IV_LENGTH);
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return {
|
||||
ciphertext: Buffer.concat([encrypted, authTag]).toString('base64'),
|
||||
iv: iv.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
export function decrypt(ciphertext: string, iv: string): string {
|
||||
const key = getKey();
|
||||
const ivBuf = Buffer.from(iv, 'base64');
|
||||
const data = Buffer.from(ciphertext, 'base64');
|
||||
const authTag = data.subarray(data.length - AUTH_TAG_LENGTH);
|
||||
const encrypted = data.subarray(0, data.length - AUTH_TAG_LENGTH);
|
||||
const decipher = createDecipheriv(ALGORITHM, key, ivBuf, { authTagLength: AUTH_TAG_LENGTH });
|
||||
decipher.setAuthTag(authTag);
|
||||
return decipher.update(encrypted) + decipher.final('utf8');
|
||||
}
|
||||
|
||||
export function hashToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
55
src/services/meta-auth.service.ts
Normal file
55
src/services/meta-auth.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { config } from '../config.js';
|
||||
|
||||
interface MetaTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface MetaProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
picture?: { data?: { url?: string } };
|
||||
}
|
||||
|
||||
export async function exchangeMetaCode(code: string): Promise<{ accessToken: string }> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.meta.appId,
|
||||
client_secret: config.meta.appSecret,
|
||||
redirect_uri: config.meta.redirectUri,
|
||||
code,
|
||||
});
|
||||
|
||||
const res = await fetch(
|
||||
`https://graph.facebook.com/v19.0/oauth/access_token?${params}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Meta token exchange failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as MetaTokenResponse;
|
||||
return { accessToken: data.access_token };
|
||||
}
|
||||
|
||||
export async function fetchMetaProfile(accessToken: string): Promise<{
|
||||
metaId: string;
|
||||
displayName: string;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
}> {
|
||||
const res = await fetch(
|
||||
`https://graph.facebook.com/v19.0/me?fields=id,name,email,picture.type(large)&access_token=${accessToken}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Meta profile fetch failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as MetaProfile;
|
||||
return {
|
||||
metaId: data.id,
|
||||
displayName: data.name,
|
||||
email: data.email ?? null,
|
||||
avatarUrl: data.picture?.data?.url ?? null,
|
||||
};
|
||||
}
|
||||
70
src/services/token-refresh.service.ts
Normal file
70
src/services/token-refresh.service.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { decrypt, encrypt } from './crypto.service.js';
|
||||
import { refreshYouTubeToken } from './youtube.service.js';
|
||||
import { refreshTwitchToken } from './twitch.service.js';
|
||||
|
||||
const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes
|
||||
const REFRESH_THRESHOLD = 15 * 60 * 1000; // Refresh tokens expiring within 15 minutes
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startTokenRefreshScheduler(prisma: PrismaClient, logger: { info: (...args: any[]) => void; error: (...args: any[]) => void }) {
|
||||
if (timer) return;
|
||||
|
||||
timer = setInterval(async () => {
|
||||
try {
|
||||
const threshold = new Date(Date.now() + REFRESH_THRESHOLD);
|
||||
const accounts = await prisma.linkedAccount.findMany({
|
||||
where: { tokenExpiresAt: { lt: threshold } },
|
||||
});
|
||||
|
||||
for (const account of accounts) {
|
||||
try {
|
||||
const refreshToken = decrypt(account.refreshTokenEnc, account.refreshTokenIv);
|
||||
|
||||
if (account.serviceId === 'YOUTUBE') {
|
||||
const result = await refreshYouTubeToken(refreshToken);
|
||||
const accessEnc = encrypt(result.accessToken);
|
||||
await prisma.linkedAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
accessTokenEnc: accessEnc.ciphertext,
|
||||
accessTokenIv: accessEnc.iv,
|
||||
tokenExpiresAt: new Date(Date.now() + result.expiresIn * 1000),
|
||||
},
|
||||
});
|
||||
} else if (account.serviceId === 'TWITCH') {
|
||||
const result = await refreshTwitchToken(refreshToken);
|
||||
const accessEnc = encrypt(result.accessToken);
|
||||
const refreshEnc = encrypt(result.refreshToken);
|
||||
await prisma.linkedAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
accessTokenEnc: accessEnc.ciphertext,
|
||||
accessTokenIv: accessEnc.iv,
|
||||
refreshTokenEnc: refreshEnc.ciphertext,
|
||||
refreshTokenIv: refreshEnc.iv,
|
||||
tokenExpiresAt: new Date(Date.now() + result.expiresIn * 1000),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Refreshed ${account.serviceId} token for account ${account.id}`);
|
||||
} catch (err) {
|
||||
logger.error(`Failed to refresh ${account.serviceId} token for account ${account.id}: ${err}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Token refresh scheduler error: ${err}`);
|
||||
}
|
||||
}, REFRESH_INTERVAL);
|
||||
|
||||
logger.info('Token refresh scheduler started');
|
||||
}
|
||||
|
||||
export function stopTokenRefreshScheduler() {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
178
src/services/twitch.service.ts
Normal file
178
src/services/twitch.service.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { config } from '../config.js';
|
||||
|
||||
interface TwitchTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
interface TwitchUser {
|
||||
id: string;
|
||||
login: string;
|
||||
display_name: string;
|
||||
profile_image_url: string;
|
||||
}
|
||||
|
||||
const SCOPES = [
|
||||
'channel:manage:broadcast',
|
||||
'channel:read:stream_key',
|
||||
'user:read:email',
|
||||
].join(' ');
|
||||
|
||||
export function getTwitchAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.twitch.clientId,
|
||||
redirect_uri: config.twitch.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: SCOPES,
|
||||
force_verify: 'true',
|
||||
state,
|
||||
});
|
||||
return `https://id.twitch.tv/oauth2/authorize?${params}`;
|
||||
}
|
||||
|
||||
export async function exchangeTwitchCode(code: string): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
const res = await fetch('https://id.twitch.tv/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: config.twitch.clientId,
|
||||
client_secret: config.twitch.clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: config.twitch.redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Twitch token exchange failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as TwitchTokenResponse;
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresIn: data.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshTwitchToken(refreshToken: string): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
const res = await fetch('https://id.twitch.tv/oauth2/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: config.twitch.clientId,
|
||||
client_secret: config.twitch.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Twitch token refresh failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as TwitchTokenResponse;
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresIn: data.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchTwitchProfile(accessToken: string): Promise<{
|
||||
accountId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
}> {
|
||||
const res = await fetch('https://api.twitch.tv/helix/users', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Client-Id': config.twitch.clientId,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Twitch profile fetch failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { data: TwitchUser[] };
|
||||
const user = data.data[0];
|
||||
if (!user) throw new Error('Twitch returned no user data');
|
||||
return {
|
||||
accountId: user.id,
|
||||
displayName: user.display_name,
|
||||
avatarUrl: user.profile_image_url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeTwitchToken(accessToken: string): Promise<void> {
|
||||
await fetch('https://id.twitch.tv/oauth2/revoke', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: config.twitch.clientId,
|
||||
token: accessToken,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Twitch Helix Stream Management ──────────────────────
|
||||
|
||||
export async function getTwitchStreamKey(
|
||||
accessToken: string,
|
||||
broadcasterId: string,
|
||||
): Promise<string> {
|
||||
const res = await fetch(
|
||||
`https://api.twitch.tv/helix/streams/key?broadcaster_id=${broadcasterId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Client-Id': config.twitch.clientId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Twitch get stream key failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as { data: [{ stream_key: string }] };
|
||||
return data.data[0].stream_key;
|
||||
}
|
||||
|
||||
export async function updateTwitchChannel(
|
||||
accessToken: string,
|
||||
broadcasterId: string,
|
||||
title: string,
|
||||
gameId: string,
|
||||
tags: string[],
|
||||
): Promise<void> {
|
||||
const body: Record<string, unknown> = {
|
||||
title,
|
||||
};
|
||||
if (gameId) body.game_id = gameId;
|
||||
if (tags.length > 0) body.tags = tags;
|
||||
|
||||
const res = await fetch(
|
||||
`https://api.twitch.tv/helix/channels?broadcaster_id=${broadcasterId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Client-Id': config.twitch.clientId,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Twitch update channel failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
221
src/services/youtube.service.ts
Normal file
221
src/services/youtube.service.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { config } from '../config.js';
|
||||
|
||||
interface GoogleTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
interface GoogleUserInfo {
|
||||
sub: string;
|
||||
name: string;
|
||||
picture?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
const SCOPES = [
|
||||
'https://www.googleapis.com/auth/youtube',
|
||||
'https://www.googleapis.com/auth/youtube.force-ssl',
|
||||
'openid',
|
||||
'profile',
|
||||
].join(' ');
|
||||
|
||||
export function getYouTubeAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.youtube.clientId,
|
||||
redirect_uri: config.youtube.redirectUri,
|
||||
response_type: 'code',
|
||||
scope: SCOPES,
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
state,
|
||||
});
|
||||
return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
|
||||
}
|
||||
|
||||
export async function exchangeYouTubeCode(code: string): Promise<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: config.youtube.clientId,
|
||||
client_secret: config.youtube.clientSecret,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: config.youtube.redirectUri,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`YouTube token exchange failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as GoogleTokenResponse;
|
||||
if (!data.refresh_token) {
|
||||
throw new Error('YouTube did not return a refresh token. User may need to revoke and re-link.');
|
||||
}
|
||||
return {
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expiresIn: data.expires_in,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refreshYouTubeToken(refreshToken: string): Promise<{
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: config.youtube.clientId,
|
||||
client_secret: config.youtube.clientSecret,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`YouTube token refresh failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as GoogleTokenResponse;
|
||||
return { accessToken: data.access_token, expiresIn: data.expires_in };
|
||||
}
|
||||
|
||||
export async function fetchYouTubeProfile(accessToken: string): Promise<{
|
||||
accountId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
}> {
|
||||
const res = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`YouTube profile fetch failed: ${res.status} ${body}`);
|
||||
}
|
||||
const data = (await res.json()) as GoogleUserInfo;
|
||||
return {
|
||||
accountId: data.sub,
|
||||
displayName: data.name,
|
||||
avatarUrl: data.picture ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeYouTubeToken(token: string): Promise<void> {
|
||||
await fetch(`https://oauth2.googleapis.com/revoke?token=${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
});
|
||||
}
|
||||
|
||||
// ── YouTube Live Streaming API ───────────────────────────
|
||||
|
||||
export interface YouTubeBroadcast {
|
||||
id: string;
|
||||
rtmpUrl: string;
|
||||
streamKey: string;
|
||||
}
|
||||
|
||||
export async function createYouTubeBroadcast(
|
||||
accessToken: string,
|
||||
title: string,
|
||||
description: string,
|
||||
privacyStatus: string,
|
||||
): Promise<YouTubeBroadcast> {
|
||||
// Create liveBroadcast
|
||||
const broadcastRes = await fetch(
|
||||
'https://www.googleapis.com/youtube/v3/liveBroadcasts?part=snippet,status,contentDetails',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
snippet: {
|
||||
title,
|
||||
description,
|
||||
scheduledStartTime: new Date().toISOString(),
|
||||
},
|
||||
status: { privacyStatus },
|
||||
contentDetails: {
|
||||
enableAutoStart: true,
|
||||
enableAutoStop: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!broadcastRes.ok) {
|
||||
const body = await broadcastRes.text();
|
||||
throw new Error(`YouTube create broadcast failed: ${broadcastRes.status} ${body}`);
|
||||
}
|
||||
const broadcast = (await broadcastRes.json()) as any;
|
||||
|
||||
// Create liveStream
|
||||
const streamRes = await fetch(
|
||||
'https://www.googleapis.com/youtube/v3/liveStreams?part=snippet,cdn',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
snippet: { title: `${title} - stream` },
|
||||
cdn: {
|
||||
frameRate: 'variable',
|
||||
ingestionType: 'rtmp',
|
||||
resolution: 'variable',
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!streamRes.ok) {
|
||||
const body = await streamRes.text();
|
||||
throw new Error(`YouTube create stream failed: ${streamRes.status} ${body}`);
|
||||
}
|
||||
const stream = (await streamRes.json()) as any;
|
||||
|
||||
// Bind stream to broadcast
|
||||
const bindRes = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/liveBroadcasts/bind?id=${broadcast.id}&part=id&streamId=${stream.id}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
if (!bindRes.ok) {
|
||||
const body = await bindRes.text();
|
||||
throw new Error(`YouTube bind broadcast failed: ${bindRes.status} ${body}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: broadcast.id,
|
||||
rtmpUrl: stream.cdn.ingestionInfo.ingestionAddress,
|
||||
streamKey: stream.cdn.ingestionInfo.streamName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function transitionYouTubeBroadcast(
|
||||
accessToken: string,
|
||||
broadcastId: string,
|
||||
status: 'live' | 'complete',
|
||||
): Promise<void> {
|
||||
const res = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/liveBroadcasts/transition?broadcastStatus=${status}&id=${broadcastId}&part=status`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`YouTube transition broadcast failed: ${res.status} ${body}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user