import { createHash, randomBytes } from "node:crypto"; import { createServer } from "node:http"; import type { AddressInfo } from "node:net"; import open from "open"; import { loadSpotifyConfig, type ResolveConfigOptions } from "./config.js"; import { loadToken, saveToken } from "./token-store.js"; import type { SpotifyConfig, SpotifyToken } from "./types.js"; export const SPOTIFY_SCOPES = [ "playlist-modify-public", "playlist-modify-private", "playlist-read-private", "playlist-read-collaborative" ]; type FetchLike = typeof fetch; export interface TokenEndpointResponse { access_token: string; refresh_token?: string; expires_in: number; } export interface AuthFlowOptions extends ResolveConfigOptions { fetchImpl?: FetchLike; openUrl?: (url: string) => Promise; now?: () => number; } export interface AuthStatus { configFound: boolean; tokenFound: boolean; tokenExpired: boolean | null; expiresAt: number | null; } function base64Url(buffer: Buffer): string { return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, ""); } export function createCodeVerifier(bytes = randomBytes(64)): string { return base64Url(bytes); } export function createCodeChallenge(verifier: string): string { return base64Url(createHash("sha256").update(verifier).digest()); } export function createState(bytes = randomBytes(32)): string { return base64Url(bytes); } export function buildAuthorizeUrl(config: SpotifyConfig, verifier: string, state: string, scopes = SPOTIFY_SCOPES): string { const params = new URLSearchParams({ response_type: "code", client_id: config.clientId, scope: scopes.join(" "), redirect_uri: config.redirectUri, state, code_challenge_method: "S256", code_challenge: createCodeChallenge(verifier) }); return `https://accounts.spotify.com/authorize?${params.toString()}`; } async function parseTokenResponse(response: Response, fallbackRefreshToken?: string, now = Date.now()): Promise { if (!response.ok) { throw new Error(`Spotify token request failed with status ${response.status}.`); } const body = await response.json() as Partial; if (typeof body.access_token !== "string" || typeof body.expires_in !== "number") { throw new Error("Spotify token response was missing required fields."); } if (!body.refresh_token && !fallbackRefreshToken) { throw new Error("Spotify token response did not include a refresh token."); } return { accessToken: body.access_token, refreshToken: body.refresh_token ?? fallbackRefreshToken ?? "", expiresAt: now + body.expires_in * 1000 }; } export async function exchangeCodeForToken( config: SpotifyConfig, code: string, verifier: string, fetchImpl: FetchLike = fetch, now = Date.now() ): Promise { const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: config.redirectUri, client_id: config.clientId, code_verifier: verifier }); const response = await fetchImpl("https://accounts.spotify.com/api/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body }); return parseTokenResponse(response, undefined, now); } export async function refreshAccessToken( config: SpotifyConfig, refreshToken: string, fetchImpl: FetchLike = fetch, now = Date.now() ): Promise { const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, client_id: config.clientId }); const response = await fetchImpl("https://accounts.spotify.com/api/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body }); return parseTokenResponse(response, refreshToken, now); } export async function refreshStoredToken(options: AuthFlowOptions = {}): Promise { const config = await loadSpotifyConfig(options); const existing = await loadToken(options); if (!existing) { throw new Error("Spotify auth token not found. Run `scripts/spotify auth` first."); } const refreshed = await refreshAccessToken(config, existing.refreshToken, options.fetchImpl ?? fetch, options.now?.() ?? Date.now()); await saveToken(refreshed, options); return refreshed; } export async function waitForAuthorizationCode(redirectUri: string, expectedState: string, timeoutMs = 300_000): Promise { const redirect = new URL(redirectUri); const hostname = redirect.hostname || "127.0.0.1"; const port = Number(redirect.port); if (!port || Number.isNaN(port)) { throw new Error("Spotify redirectUri must include an explicit local port."); } return new Promise((resolvePromise, reject) => { let settled = false; const server = createServer((request, response) => { const finish = (callback: () => void): void => { if (settled) { return; } settled = true; clearTimeout(timer); server.close(); callback(); }; try { const requestUrl = new URL(request.url ?? "/", redirectUri); if (requestUrl.pathname !== redirect.pathname) { response.writeHead(404).end("Not found"); return; } const error = requestUrl.searchParams.get("error"); if (error) { throw new Error(`Spotify authorization failed: ${error}`); } const state = requestUrl.searchParams.get("state"); if (state !== expectedState) { throw new Error("Spotify authorization state mismatch."); } const code = requestUrl.searchParams.get("code"); if (!code) { throw new Error("Spotify authorization callback did not include a code."); } response.writeHead(200, { "content-type": "text/plain" }).end("Spotify authorization complete. You can close this tab."); finish(() => resolvePromise(code)); } catch (error) { response.writeHead(400, { "content-type": "text/plain" }).end("Spotify authorization failed."); finish(() => reject(error)); } }); const timer = setTimeout(() => { if (settled) { return; } settled = true; server.close(); reject(new Error("Spotify authorization timed out waiting for the browser callback.")); }, timeoutMs); server.once("error", reject); server.listen(port, hostname, () => { const address = server.address() as AddressInfo; if (address.port !== port) { server.close(); reject(new Error("Spotify authorization callback server bound to the wrong port.")); } }); }); } export async function runAuthorizationFlow(options: AuthFlowOptions = {}): Promise { const config = await loadSpotifyConfig(options); const verifier = createCodeVerifier(); const state = createState(); const authUrl = buildAuthorizeUrl(config, verifier, state); await (options.openUrl ?? open)(authUrl); const code = await waitForAuthorizationCode(config.redirectUri, state); const token = await exchangeCodeForToken(config, code, verifier, options.fetchImpl ?? fetch, options.now?.() ?? Date.now()); await saveToken(token, options); return token; } export async function getAuthStatus(options: ResolveConfigOptions & { now?: () => number } = {}): Promise { let configFound = false; try { await loadSpotifyConfig(options); configFound = true; } catch { configFound = false; } const token = await loadToken(options); return { configFound, tokenFound: Boolean(token), tokenExpired: token ? token.expiresAt <= (options.now?.() ?? Date.now()) : null, expiresAt: token?.expiresAt ?? null }; }