feat(spotify): implement milestone M2 auth

This commit is contained in:
2026-04-12 01:36:27 -05:00
parent f7dfb7d71d
commit c8c0876b7c
8 changed files with 670 additions and 5 deletions
+230
View File
@@ -0,0 +1,230 @@
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
};
}
+20 -2
View File
@@ -3,6 +3,8 @@
import minimist from "minimist";
import { fileURLToPath } from "node:url";
import { getAuthStatus, runAuthorizationFlow } from "./auth.js";
export interface CliDeps {
stdout: Pick<NodeJS.WriteStream, "write">;
stderr: Pick<NodeJS.WriteStream, "write">;
@@ -37,8 +39,24 @@ function notImplemented(command: string): CommandHandler {
export function createDefaultHandlers(): CommandHandlers {
return {
auth: notImplemented("auth"),
status: notImplemented("status"),
auth: async (_args, deps) => {
await runAuthorizationFlow();
deps.stdout.write("Spotify authorization complete.\n");
return 0;
},
status: async (args, deps) => {
const status = await getAuthStatus();
if (args.json) {
deps.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
} else {
deps.stdout.write(`Spotify config: ${status.configFound ? "found" : "missing"}\n`);
deps.stdout.write(`Spotify token: ${status.tokenFound ? "found" : "missing"}\n`);
if (status.tokenFound) {
deps.stdout.write(`Spotify token expired: ${status.tokenExpired ? "yes" : "no"}\n`);
}
}
return status.configFound && status.tokenFound ? 0 : 1;
},
search: notImplemented("search"),
"list-playlists": notImplemented("list-playlists"),
"create-playlist": notImplemented("create-playlist"),
+116
View File
@@ -0,0 +1,116 @@
import { access, readFile } from "node:fs/promises";
import { constants } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import type { SpotifyConfig } from "./types.js";
export interface SpotifyPaths {
configDir: string;
configPath: string;
tokenPath: string;
}
export interface ResolveConfigOptions {
env?: NodeJS.ProcessEnv;
startDir?: string;
homeDir?: string;
}
const skillDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function pathExists(path: string): Promise<boolean> {
return access(path, constants.F_OK).then(() => true, () => false);
}
function expandHome(path: string, homeDir: string): string {
if (path === "~") {
return homeDir;
}
if (path.startsWith("~/")) {
return join(homeDir, path.slice(2));
}
return path;
}
async function findUpwardCredentialDir(startDir: string): Promise<string | undefined> {
let current = resolve(startDir);
while (true) {
const candidate = join(current, ".clawdbot", "credentials", "spotify");
if (await pathExists(candidate)) {
return candidate;
}
const parent = dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
export async function resolveSpotifyPaths(options: ResolveConfigOptions = {}): Promise<SpotifyPaths> {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const candidates: Array<Promise<string | undefined>> = [];
if (env.SPOTIFY_CONFIG_DIR) {
candidates.push(Promise.resolve(resolve(expandHome(env.SPOTIFY_CONFIG_DIR, homeDir))));
}
candidates.push(Promise.resolve(join(homeDir, ".openclaw", "workspace", ".clawdbot", "credentials", "spotify")));
candidates.push(findUpwardCredentialDir(options.startDir ?? skillDir));
candidates.push(Promise.resolve(join(homeDir, ".clawdbot", "credentials", "spotify")));
for (const candidatePromise of candidates) {
const candidate = await candidatePromise;
if (candidate && await pathExists(candidate)) {
return {
configDir: candidate,
configPath: join(candidate, "config.json"),
tokenPath: join(candidate, "token.json")
};
}
}
const fallback = env.SPOTIFY_CONFIG_DIR
? resolve(expandHome(env.SPOTIFY_CONFIG_DIR, homeDir))
: join(homeDir, ".openclaw", "workspace", ".clawdbot", "credentials", "spotify");
return {
configDir: fallback,
configPath: join(fallback, "config.json"),
tokenPath: join(fallback, "token.json")
};
}
export async function loadSpotifyConfig(options: ResolveConfigOptions = {}): Promise<SpotifyConfig> {
const paths = await resolveSpotifyPaths(options);
let raw: string;
try {
raw = await readFile(paths.configPath, "utf8");
} catch {
throw new Error(`Spotify config not found. Create ${paths.configPath} with clientId and redirectUri.`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Spotify config is not valid JSON.");
}
if (
!parsed ||
typeof parsed !== "object" ||
typeof (parsed as SpotifyConfig).clientId !== "string" ||
typeof (parsed as SpotifyConfig).redirectUri !== "string" ||
!(parsed as SpotifyConfig).clientId.trim() ||
!(parsed as SpotifyConfig).redirectUri.trim()
) {
throw new Error("Spotify config must include non-empty clientId and redirectUri.");
}
return {
clientId: (parsed as SpotifyConfig).clientId,
redirectUri: (parsed as SpotifyConfig).redirectUri
};
}
+80
View File
@@ -0,0 +1,80 @@
import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { resolveSpotifyPaths, type ResolveConfigOptions } from "./config.js";
import type { SpotifyToken } from "./types.js";
export interface TokenStoreOptions extends ResolveConfigOptions {
tokenPath?: string;
}
function isSpotifyToken(value: unknown): value is SpotifyToken {
return Boolean(
value &&
typeof value === "object" &&
typeof (value as SpotifyToken).accessToken === "string" &&
typeof (value as SpotifyToken).refreshToken === "string" &&
typeof (value as SpotifyToken).expiresAt === "number"
);
}
export async function resolveTokenPath(options: TokenStoreOptions = {}): Promise<string> {
if (options.tokenPath) {
return options.tokenPath;
}
return (await resolveSpotifyPaths(options)).tokenPath;
}
export async function loadToken(options: TokenStoreOptions = {}): Promise<SpotifyToken | undefined> {
const tokenPath = await resolveTokenPath(options);
let raw: string;
try {
raw = await readFile(tokenPath, "utf8");
} catch {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Spotify token store is not valid JSON.");
}
if (!isSpotifyToken(parsed)) {
throw new Error("Spotify token store has an invalid token shape.");
}
return parsed;
}
export async function saveToken(token: SpotifyToken, options: TokenStoreOptions = {}): Promise<string> {
const tokenPath = await resolveTokenPath(options);
await mkdir(dirname(tokenPath), { recursive: true, mode: 0o700 });
const tempPath = `${tokenPath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tempPath, `${JSON.stringify(token, null, 2)}\n`, { mode: 0o600 });
try {
await chmod(tempPath, 0o600);
} catch {
// chmod can fail on non-POSIX filesystems; the initial mode still keeps normal macOS writes private.
}
await rename(tempPath, tokenPath);
try {
await chmod(tokenPath, 0o600);
} catch {
// Best effort only; token values are never printed.
}
return tokenPath;
}
export function tokenNeedsRefresh(token: SpotifyToken, now = Date.now(), skewMs = 60_000): boolean {
return token.expiresAt <= now + skewMs;
}
export async function tokenFileMode(options: TokenStoreOptions = {}): Promise<number | undefined> {
const tokenPath = await resolveTokenPath(options);
try {
return (await stat(tokenPath)).mode & 0o777;
} catch {
return undefined;
}
}