const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"; const configuredBaseUrl = ( process.env as Record ).SPOTIRIP_BASE_URL?.trim().replace(/\/+$/, ""); export const SPOTIRIP_BASE_URL = configuredBaseUrl || "https://spotirip.auroradragon.dev"; export const SPOTIFY_REDIRECT_URI = SPOTIRIP_BASE_URL + "/spotify/callback"; type StoredSpotifySession = { accessToken?: string; refreshToken?: string; expiresAt?: number; }; type TokenResponse = { access_token: string; token_type: string; scope?: string; expires_in: number; refresh_token?: string; }; export function parseCookies(request: Request) { const raw = request.headers.get("cookie") || ""; const cookies: Record = {}; for (const part of raw.split(";")) { const index = part.indexOf("="); if (index < 0) continue; const key = part.slice(0, index).trim(); const value = part.slice(index + 1).trim(); if (key) cookies[key] = decodeURIComponent(value); } return cookies; } export function readSpotifySession(request: Request): StoredSpotifySession { const cookies = parseCookies(request); const expiresAt = Number(cookies.spotirip_spotify_expires || 0); return { accessToken: cookies.spotirip_spotify_access, refreshToken: cookies.spotirip_spotify_refresh, expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0, }; } export function makeCookie( name: string, value: string, options: { maxAge?: number; httpOnly?: boolean } = {}, ) { const segments = [ `${name}=${encodeURIComponent(value)}`, "Path=/", "Secure", "SameSite=Lax", ]; if (options.httpOnly !== false) segments.push("HttpOnly"); if (typeof options.maxAge === "number") segments.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`); return segments.join("; "); } export function clearCookie(name: string) { return `${name}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`; } function spotifyBasicAuth() { const clientId = process.env.SPOTIFY_CLIENT_ID; const clientSecret = process.env.SPOTIFY_CLIENT_SECRET; if (!clientId || !clientSecret) { throw new Error("Spotify credentials are not configured."); } return "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); } export async function exchangeSpotifyCode(code: string) { const response = await fetch(SPOTIFY_TOKEN_URL, { method: "POST", headers: { Authorization: spotifyBasicAuth(), "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: SPOTIFY_REDIRECT_URI, }), }); if (!response.ok) { const message = await response.text(); throw new Error(`Spotify token exchange failed (${response.status}): ${message.slice(0, 300)}`); } return (await response.json()) as TokenResponse; } export async function refreshSpotifyToken(refreshToken: string) { const response = await fetch(SPOTIFY_TOKEN_URL, { method: "POST", headers: { Authorization: spotifyBasicAuth(), "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, }), }); if (!response.ok) { const message = await response.text(); throw new Error(`Spotify token refresh failed (${response.status}): ${message.slice(0, 300)}`); } return (await response.json()) as TokenResponse; } export async function ensureSpotifyAccessToken(request: Request) { const current = readSpotifySession(request); const now = Date.now(); if (current.accessToken && current.expiresAt && current.expiresAt > now + 60_000) { return { accessToken: current.accessToken, setCookies: [] as string[] }; } if (!current.refreshToken) { return { accessToken: null, setCookies: [] as string[] }; } const refreshed = await refreshSpotifyToken(current.refreshToken); const expiresAt = Date.now() + refreshed.expires_in * 1000; const setCookies = [ makeCookie("spotirip_spotify_access", refreshed.access_token, { maxAge: refreshed.expires_in }), makeCookie("spotirip_spotify_expires", String(expiresAt), { maxAge: refreshed.expires_in }), ]; if (refreshed.refresh_token) { setCookies.push( makeCookie("spotirip_spotify_refresh", refreshed.refresh_token, { maxAge: 60 * 60 * 24 * 30 }), ); } return { accessToken: refreshed.access_token, setCookies }; } export function appendSetCookies(headers: Headers, cookies: string[]) { for (const cookie of cookies) headers.append("Set-Cookie", cookie); }