/** * OAuth Google / Twitch pour l'import des favoris et abonnements. * * - Google (YouTube) : scopes `youtube.readonly` (+ profil). Importe les * abonnements (`subscriptions.list?mine=true`) et les favoris * (`videos.list?myRating=like`). * - Twitch : scopes `user:read:follows user:read:subscriptions`. Importe les * chaînes suivies (`/helix/users/follows?from_id=`). Twitch n'a pas de * notion de "like" vidéo : seul l'import abonnements est proposé. * * Les tokens sont stockés en SQLite (instance locale / auto-hébergée). * Ne jamais logger access_token / refresh_token / client_secret. */ const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; const TWITCH_AUTH_URL = 'https://id.twitch.tv/oauth2/authorize'; const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token'; const GOOGLE_SCOPES = [ 'openid', 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/youtube.readonly', ].join(' '); const TWITCH_SCOPES = ['user:read:follows', 'user:read:subscriptions'].join(' '); // state -> { userId, provider, createdAt } (mémoire, 10 min). const pendingStates = new Map(); function randomState() { try { const { randomBytes } = require('node:crypto'); return randomBytes(16).toString('hex'); } catch {} return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; } export function createOAuthState(userId, provider, purpose = 'link') { const state = randomState(); pendingStates.set(state, { userId: userId == null ? null : String(userId), provider: String(provider), purpose: String(purpose || 'link'), createdAt: Date.now(), }); // Purge opportuniste. try { const now = Date.now(); for (const [k, v] of pendingStates) { if (now - v.createdAt > 10 * 60 * 1000) pendingStates.delete(k); } } catch {} return state; } export function consumeOAuthState(state) { const entry = pendingStates.get(String(state || '')); if (!entry) return null; pendingStates.delete(String(state)); if (Date.now() - entry.createdAt > 10 * 60 * 1000) return null; return entry; } export function oauthStatus() { const googleId = String(process.env.GOOGLE_CLIENT_ID || '').trim(); const googleSecret = String(process.env.GOOGLE_CLIENT_SECRET || '').trim(); const twitchId = String(process.env.TWITCH_CLIENT_ID || '').trim(); const twitchSecret = String(process.env.TWITCH_CLIENT_SECRET || '').trim(); return { google: { configured: Boolean(googleId && googleSecret), missing: [...(!googleId ? ['GOOGLE_CLIENT_ID'] : []), ...(!googleSecret ? ['GOOGLE_CLIENT_SECRET'] : [])], }, twitch: { configured: Boolean(twitchId && twitchSecret), missing: [...(!twitchId ? ['TWITCH_CLIENT_ID'] : []), ...(!twitchSecret ? ['TWITCH_CLIENT_SECRET'] : [])], }, }; } /** Base publique de l'app (pour la redirect_uri). Priorité au .env explicite. */ export function appBaseUrl(req) { const explicit = String(process.env.OAUTH_APP_BASE_URL || '').trim().replace(/\/+$/, ''); if (explicit) return explicit; try { const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim() || 'http'; const host = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || req?.get?.('host') || '').trim(); if (host) return `${proto}://${host}`; } catch {} return 'http://localhost:4200'; } export function redirectUriFor(provider, req) { const p = String(provider); if (p === 'google') { const explicit = String(process.env.GOOGLE_REDIRECT_URI || '').trim(); if (explicit) return explicit; } if (p === 'twitch') { const explicit = String(process.env.TWITCH_REDIRECT_URI || '').trim(); if (explicit) return explicit; } return `${appBaseUrl(req).replace(/\/+$/, '')}/api/oauth/${p}/callback`; } export function buildAuthUrl(provider, state, req) { const p = String(provider); if (p === 'google') { const clientId = String(process.env.GOOGLE_CLIENT_ID || '').trim(); if (!clientId) throw Object.assign(new Error('google_oauth_not_configured'), { status: 503 }); const qs = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUriFor('google', req), response_type: 'code', scope: GOOGLE_SCOPES, access_type: 'offline', prompt: 'consent', state, }); return `${GOOGLE_AUTH_URL}?${qs.toString()}`; } if (p === 'twitch') { const clientId = String(process.env.TWITCH_CLIENT_ID || '').trim(); if (!clientId) throw Object.assign(new Error('twitch_oauth_not_configured'), { status: 503 }); const qs = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUriFor('twitch', req), response_type: 'code', scope: TWITCH_SCOPES, state, }); return `${TWITCH_AUTH_URL}?${qs.toString()}`; } throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 }); } async function postForm(url, params) { const body = new URLSearchParams(params); const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { const err = new Error(`oauth_token_failed_${resp.status}`); err.status = 502; err.details = data; throw err; } return data; } export async function exchangeCode(provider, code, req) { const p = String(provider); if (p === 'google') { const data = await postForm(GOOGLE_TOKEN_URL, { code: String(code), client_id: String(process.env.GOOGLE_CLIENT_ID || ''), client_secret: String(process.env.GOOGLE_CLIENT_SECRET || ''), redirect_uri: redirectUriFor('google', req), grant_type: 'authorization_code', }); return { accessToken: data.access_token, refreshToken: data.refresh_token || null, expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null, scopes: data.scope || GOOGLE_SCOPES, }; } if (p === 'twitch') { const data = await postForm(TWITCH_TOKEN_URL, { code: String(code), client_id: String(process.env.TWITCH_CLIENT_ID || ''), client_secret: String(process.env.TWITCH_CLIENT_SECRET || ''), redirect_uri: redirectUriFor('twitch', req), grant_type: 'authorization_code', }); return { accessToken: data.access_token, refreshToken: data.refresh_token || null, expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null, scopes: Array.isArray(data.scope) ? data.scope.join(' ') : TWITCH_SCOPES, }; } throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 }); } export async function refreshAccessToken(provider, refreshToken) { const p = String(provider); if (p === 'google') { const data = await postForm(GOOGLE_TOKEN_URL, { refresh_token: String(refreshToken), client_id: String(process.env.GOOGLE_CLIENT_ID || ''), client_secret: String(process.env.GOOGLE_CLIENT_SECRET || ''), grant_type: 'refresh_token', }); return { accessToken: data.access_token, refreshToken: data.refresh_token || refreshToken, expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null, }; } if (p === 'twitch') { const data = await postForm(TWITCH_TOKEN_URL, { refresh_token: String(refreshToken), client_id: String(process.env.TWITCH_CLIENT_ID || ''), client_secret: String(process.env.TWITCH_CLIENT_SECRET || ''), grant_type: 'refresh_token', }); return { accessToken: data.access_token, refreshToken: data.refresh_token || refreshToken, expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null, }; } throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 }); } async function getJson(url, accessToken, extraHeaders = {}) { const resp = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}`, ...extraHeaders }, }); const data = await resp.json().catch(() => ({})); if (!resp.ok) { const err = new Error(`oauth_api_failed_${resp.status}`); err.status = resp.status === 401 ? 401 : 502; err.details = data; throw err; } return data; } // -------------------- Google (YouTube) -------------------- export async function fetchGoogleProfile(accessToken) { const data = await getJson('https://www.googleapis.com/oauth2/v2/userinfo', accessToken); return { id: String(data.id || ''), email: String(data.email || ''), verifiedEmail: data.verified_email !== false, displayName: String(data.name || data.email || 'Google'), avatarUrl: String(data.picture || ''), }; } export async function fetchGoogleSubscriptions(accessToken, max = 50) { const out = []; let pageToken = ''; while (out.length < max) { const qs = new URLSearchParams({ part: 'snippet', mine: 'true', maxResults: String(Math.min(50, max - out.length)), order: 'alphabetical', }); if (pageToken) qs.set('pageToken', pageToken); const data = await getJson(`https://www.googleapis.com/youtube/v3/subscriptions?${qs.toString()}`, accessToken); for (const item of Array.isArray(data?.items) ? data.items : []) { const sn = item?.snippet || {}; const channelId = sn?.resourceId?.channelId || ''; if (!channelId) continue; out.push({ provider: 'youtube', externalId: channelId, title: sn?.title || channelId, handle: null, avatarUrl: sn?.thumbnails?.default?.url || sn?.thumbnails?.medium?.url || null, url: `https://www.youtube.com/channel/${channelId}`, }); } pageToken = data?.nextPageToken || ''; if (!pageToken) break; } return out; } export async function fetchGoogleLiked(accessToken, max = 25) { const qs = new URLSearchParams({ part: 'snippet,contentDetails', myRating: 'like', maxResults: String(Math.min(50, max)), }); const data = await getJson(`https://www.googleapis.com/youtube/v3/videos?${qs.toString()}`, accessToken); const out = []; for (const item of Array.isArray(data?.items) ? data.items : []) { const id = item?.id || ''; const sn = item?.snippet || {}; if (!id) continue; out.push({ provider: 'youtube', videoId: String(id), title: sn?.title || '', thumbnail: sn?.thumbnails?.medium?.url || sn?.thumbnails?.default?.url || '', }); } return out; } // -------------------- Twitch -------------------- function twitchClientId() { return String(process.env.TWITCH_CLIENT_ID || '').trim(); } export async function fetchTwitchProfile(accessToken) { const data = await getJson('https://api.twitch.tv/helix/users', accessToken, { 'Client-Id': twitchClientId() }); const u = Array.isArray(data?.data) ? data.data[0] : null; if (!u) throw Object.assign(new Error('twitch_profile_failed'), { status: 502 }); return { id: String(u.id || ''), displayName: String(u.display_name || u.login || 'Twitch'), avatarUrl: String(u.profile_image_url || ''), login: String(u.login || ''), }; } export async function fetchTwitchFollows(accessToken, twitchUserId, max = 100) { const out = []; let cursor = ''; while (out.length < max) { const qs = new URLSearchParams({ from_id: String(twitchUserId), first: String(Math.min(100, max - out.length)), }); if (cursor) qs.set('after', cursor); const data = await getJson(`https://api.twitch.tv/helix/users/follows?${qs.toString()}`, accessToken, { 'Client-Id': twitchClientId(), }); const list = Array.isArray(data?.data) ? data.data : []; // Enrichit les logins via /helix/users?id= (display_name + avatar). const ids = list.map((f) => f?.to_id).filter(Boolean).slice(0, 100); let usersById = new Map(); if (ids.length) { try { const uqs = new URLSearchParams(); ids.forEach((id) => uqs.append('id', String(id))); const udata = await getJson(`https://api.twitch.tv/helix/users?${uqs.toString()}`, accessToken, { 'Client-Id': twitchClientId(), }); for (const u of Array.isArray(udata?.data) ? udata.data : []) { usersById.set(String(u.id), u); } } catch {} } for (const f of list) { const toId = String(f?.to_id || ''); if (!toId) continue; const u = usersById.get(toId); const login = String(u?.login || f?.to_name || ''); out.push({ provider: 'twitch', externalId: login || toId, twitchUserId: toId, title: String(u?.display_name || f?.to_name || login || toId), handle: login || null, avatarUrl: String(u?.profile_image_url || ''), url: login ? `https://www.twitch.tv/${login}` : '', }); } cursor = data?.pagination?.cursor || ''; if (!cursor || !list.length) break; } return out; }