import type { Cookies } from '@sveltejs/kit'; import { dev } from '$app/environment'; import * as fs from 'node:fs'; import * as path from 'node:path'; const TOKEN_URL = 'https://api.beatleader.com/oauth2/token'; const ACCESS_COOKIE = 'bl_access_token'; const REFRESH_COOKIE = 'bl_refresh_token'; const EXPIRES_COOKIE = 'bl_expires_at'; const SESSION_COOKIE = 'bl_session_cookie'; const STATE_COOKIE = 'bl_oauth_state'; const REDIRECT_COOKIE = 'bl_redirect_to'; // Persistent storage locations const DATA_DIR = '.data'; const CREDS_FILE = 'beatleader_oauth.json'; const TOKENS_FILE = 'beatleader_tokens.json'; function cookieOptions() { return { path: '/', httpOnly: true as const, sameSite: 'lax' as const, secure: dev ? false : true }; } export function createOAuthState(): string { // Use crypto.randomUUID for sufficiently random state return crypto.randomUUID(); } export function setStateCookie(cookies: Cookies, state: string): void { cookies.set(STATE_COOKIE, state, { ...cookieOptions(), maxAge: 600 }); // 10 min } export function consumeAndValidateState(cookies: Cookies, received: string | null): boolean { const stored = cookies.get(STATE_COOKIE); cookies.delete(STATE_COOKIE, cookieOptions()); return Boolean(stored && received && stored === received); } export function setRedirectCookie(cookies: Cookies, redirectTo: string | null | undefined): void { if (!redirectTo) return; try { const url = new URL(redirectTo, 'http://dummy'); const value = url.pathname + (url.search || '') + (url.hash || ''); cookies.set(REDIRECT_COOKIE, value, { ...cookieOptions(), maxAge: 600 }); } catch { // ignore invalid redirect } } export function consumeRedirectCookie(cookies: Cookies): string | null { const value = cookies.get(REDIRECT_COOKIE) ?? null; cookies.delete(REDIRECT_COOKIE, cookieOptions()); return value; } export function setTokens( cookies: Cookies, tokenData: { access_token: string; refresh_token?: string; expires_in?: number } ): void { const expiresAt = Date.now() + ((tokenData.expires_in ?? 3600) * 1000); cookies.set(ACCESS_COOKIE, tokenData.access_token, { ...cookieOptions(), maxAge: tokenData.expires_in ?? 3600 }); if (tokenData.refresh_token) { // 30 days by default for refresh; adjust as needed cookies.set(REFRESH_COOKIE, tokenData.refresh_token, { ...cookieOptions(), maxAge: 30 * 24 * 3600 }); } cookies.set(EXPIRES_COOKIE, String(expiresAt), { ...cookieOptions(), maxAge: 30 * 24 * 3600 }); // Persist token data to disk for reuse across restarts (single-user assumption) try { writeJsonPersistent(TOKENS_FILE, { access_token: tokenData.access_token, refresh_token: tokenData.refresh_token ?? null, expires_at: expiresAt }); } catch { // ignore persistence errors } } export function clearTokens(cookies: Cookies): void { cookies.delete(ACCESS_COOKIE, cookieOptions()); cookies.delete(REFRESH_COOKIE, cookieOptions()); cookies.delete(EXPIRES_COOKIE, cookieOptions()); try { deletePersistent(TOKENS_FILE); } catch { // ignore } } /** * Persist BeatLeader website session cookies captured from upstream Set-Cookie headers. * Only cookie name=value pairs are stored (attributes are discarded). Stored both in httpOnly cookie and on disk. */ export function setBeatLeaderSessionFromSetCookieHeaders(cookies: Cookies, setCookieHeaders: string[] | undefined | null): void { if (!setCookieHeaders || setCookieHeaders.length === 0) return; const nameValuePairs: string[] = []; for (const raw of setCookieHeaders) { const firstSemi = raw.indexOf(';'); const pair = firstSemi === -1 ? raw.trim() : raw.slice(0, firstSemi).trim(); if (!pair) continue; // Filter out obviously invalid pairs if (!pair.includes('=')) continue; const [name, value] = pair.split('='); if (!name || value === undefined) continue; nameValuePairs.push(`${name}=${value}`); } if (nameValuePairs.length === 0) return; try { cookies.set(SESSION_COOKIE, JSON.stringify(nameValuePairs), { ...cookieOptions(), maxAge: 30 * 24 * 3600 }); } catch {} try { writeJsonPersistent('beatleader_session.json', { cookies: nameValuePairs }); } catch {} } /** * Returns a Cookie header string ("a=b; c=d") suitable for upstream requests, if a BL session is stored. */ export function getBeatLeaderSessionCookieHeader(cookies: Cookies): string | null { try { const raw = cookies.get(SESSION_COOKIE); if (raw) { const arr = JSON.parse(raw) as string[]; if (Array.isArray(arr) && arr.length > 0) return arr.join('; '); } } catch {} try { const persisted = readJsonPersistent<{ cookies?: string[] }>('beatleader_session.json'); if (persisted?.cookies && persisted.cookies.length > 0) return persisted.cookies.join('; '); } catch {} return null; } export function clearBeatLeaderSession(cookies: Cookies): void { cookies.delete(SESSION_COOKIE, cookieOptions()); try { deletePersistent('beatleader_session.json'); } catch {} } export function getAccessTokenFromCookies(cookies: Cookies): { token: string | null; expiresAt: number | null } { const token = cookies.get(ACCESS_COOKIE) ?? null; const expiresRaw = cookies.get(EXPIRES_COOKIE); const expiresAt = expiresRaw ? Number(expiresRaw) : null; return { token, expiresAt }; } export async function refreshAccessToken(cookies: Cookies): Promise { const refreshToken = cookies.get(REFRESH_COOKIE); let tokenToUse: string | undefined = refreshToken ?? undefined; if (!tokenToUse) { // Try persistent token store const persisted = readJsonPersistent<{ refresh_token?: string | null }>(TOKENS_FILE); tokenToUse = (persisted?.refresh_token ?? undefined) as string | undefined; } if (!tokenToUse) return null; const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE); const clientId = creds?.client_id; const clientSecret = creds?.client_secret; if (!clientId || !clientSecret) return null; const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', client_id: clientId, client_secret: clientSecret, refresh_token: tokenToUse }) }); if (!res.ok) { clearTokens(cookies); return null; } const tokenData = await res.json(); setTokens(cookies, tokenData); return tokenData.access_token as string; } export async function getValidAccessToken(cookies: Cookies): Promise { const { token, expiresAt } = getAccessTokenFromCookies(cookies); if (token && expiresAt && Date.now() < expiresAt - 10_000) { return token; // still valid (with 10s skew) } // Try refresh const refreshed = await refreshAccessToken(cookies); if (refreshed) return refreshed; // As last resort, if we have a persisted access token that may still be valid, use it const persisted = readJsonPersistent<{ access_token?: string | null; expires_at?: number | null }>(TOKENS_FILE); if (persisted?.access_token && persisted?.expires_at && Date.now() < (persisted.expires_at - 10_000)) { cookies.set(ACCESS_COOKIE, persisted.access_token, { ...cookieOptions(), maxAge: Math.floor((persisted.expires_at - Date.now()) / 1000) }); cookies.set(EXPIRES_COOKIE, String(persisted.expires_at), { ...cookieOptions(), maxAge: 30 * 24 * 3600 }); return persisted.access_token; } return null; } export function buildAuthorizeUrl(origin: string, scopes: string[]): URL { const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE); const clientId = creds?.client_id; if (!clientId) throw new Error('BeatLeader OAuth is not configured. Visit /tools/beatleader-oauth to set it up.'); const redirectUri = `${origin}/auth/beatleader/callback`; const url = new URL('https://api.beatleader.com/oauth2/authorize'); url.searchParams.set('client_id', clientId); // BeatLeader expects scopes without the "scp:" prefix in the authorization request const requestedScopes = scopes.map(s => s.replace(/^scp:/, '')).join(' '); url.searchParams.set('scope', requestedScopes); url.searchParams.set('response_type', 'code'); url.searchParams.set('redirect_uri', redirectUri); return url; } export async function exchangeCodeForTokens(origin: string, code: string): Promise<{ access_token: string; refresh_token?: string; expires_in?: number } | null> { const creds = readJsonPersistent<{ client_id?: string; client_secret?: string }>(CREDS_FILE); const clientId = creds?.client_id; const clientSecret = creds?.client_secret; if (!clientId || !clientSecret) return null; const redirectUri = `${origin}/auth/beatleader/callback`; const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: clientId, client_secret: clientSecret, code, redirect_uri: redirectUri }) }); if (!res.ok) return null; return (await res.json()) as any; } // Persistent storage helpers (single-user local setup) function ensureDataDir(): void { try { if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); } catch { // ignore } } function resolvePath(name: string): string { return path.join(process.cwd(), DATA_DIR, name); } function writeJsonPersistent(name: string, data: T): void { ensureDataDir(); fs.writeFileSync(resolvePath(name), JSON.stringify(data, null, 2), 'utf-8'); } function readJsonPersistent(name: string): T | null { try { const p = resolvePath(name); if (!fs.existsSync(p)) return null; const raw = fs.readFileSync(p, 'utf-8'); return JSON.parse(raw) as T; } catch { return null; } } function deletePersistent(name: string): void { try { const p = resolvePath(name); if (fs.existsSync(p)) fs.unlinkSync(p); } catch { // ignore } } export function storeOAuthCredentials(input: { client_id: string; client_secret: string; scopes?: string[]; redirect_urls?: string[] }): void { writeJsonPersistent(CREDS_FILE, input); } export function readOAuthCredentials(): { client_id: string; client_secret: string; scopes?: string[]; redirect_urls?: string[] } | null { return readJsonPersistent(CREDS_FILE); }