231 lines
7.6 KiB
TypeScript
231 lines
7.6 KiB
TypeScript
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<unknown>;
|
|
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<SpotifyToken> {
|
|
if (!response.ok) {
|
|
throw new Error(`Spotify token request failed with status ${response.status}.`);
|
|
}
|
|
const body = await response.json() as Partial<TokenEndpointResponse>;
|
|
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<SpotifyToken> {
|
|
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<SpotifyToken> {
|
|
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<SpotifyToken> {
|
|
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<string> {
|
|
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<SpotifyToken> {
|
|
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<AuthStatus> {
|
|
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
|
|
};
|
|
}
|