Phases 2-4: Auth, providers, stream management

This commit is contained in:
2026-02-23 15:32:24 +01:00
parent 8ea3279c3b
commit 538c24c58f
14 changed files with 1530 additions and 0 deletions

View 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');
}

View 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,
};
}

View 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;
}
}

View 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}`);
}
}

View 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}`);
}
}