diff --git a/src/music/spotify/spotify-oauth.test.ts b/src/music/spotify/spotify-oauth.test.ts new file mode 100644 index 0000000..481d3e9 --- /dev/null +++ b/src/music/spotify/spotify-oauth.test.ts @@ -0,0 +1,297 @@ +import { describe, it, expect, vi } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + SpotifyOAuth, + SPOTIFY_CONTROL_SCOPES, + generateCodeVerifier, + codeChallengeS256, + createFileOAuthTokenStore, + type OAuthTokens, + type OAuthTokenStore, +} from "./spotify-oauth.js"; + +// Correction C3.2: the control OAuth REQUIRES a user-provided client_id (their +// own Spotify Developer app) + caller-supplied loopback redirect. There is NO +// librespot public-client / :5588 default. +const CLIENT_ID = "test-client-id"; +const REDIRECT_URI = "http://127.0.0.1:8888/api/spotify/callback"; + +/** In-memory store exposing `.value` so tests can assert persistence. */ +function memStore( + initial: OAuthTokens | null = null, +): OAuthTokenStore & { value: OAuthTokens | null } { + const s = { + value: initial, + load() { + return s.value; + }, + save(t: OAuthTokens) { + s.value = t; + }, + clear() { + s.value = null; + }, + }; + return s; +} + +describe("PKCE helpers", () => { + it("generateCodeVerifier returns 64 chars from the unreserved set", () => { + const v = generateCodeVerifier(); + expect(v).toHaveLength(64); + expect(v).toMatch(/^[A-Za-z0-9\-._~]{64}$/); + expect(generateCodeVerifier()).not.toBe(v); // random + }); + + it("codeChallengeS256 is base64url(sha256) with no padding (43 chars)", () => { + const c = codeChallengeS256("abc123"); + expect(c).toHaveLength(43); // 32-byte digest -> 43 base64url chars + expect(c).not.toContain("="); + expect(c).toMatch(/^[A-Za-z0-9_-]+$/); + }); +}); + +describe("SpotifyOAuth.buildAuthorizeUrl", () => { + it("builds accounts.spotify.com/authorize with the caller's clientId + redirectUri + S256", () => { + const oauth = new SpotifyOAuth({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + store: memStore(), + }); + const { url, state } = oauth.buildAuthorizeUrl(); + const u = new URL(url); + expect(u.origin + u.pathname).toBe("https://accounts.spotify.com/authorize"); + const p = u.searchParams; + expect(p.get("client_id")).toBe(CLIENT_ID); + expect(p.get("response_type")).toBe("code"); + expect(p.get("redirect_uri")).toBe(REDIRECT_URI); + expect(p.get("code_challenge_method")).toBe("S256"); + expect(p.get("code_challenge")).toHaveLength(43); + expect(p.get("scope")).toBe(SPOTIFY_CONTROL_SCOPES); + expect(p.get("state")).toBe(state); + expect(state).toMatch(/^[0-9a-f]{32}$/); + expect(oauth.getClientId()).toBe(CLIENT_ID); + expect(oauth.getRedirectUri()).toBe(REDIRECT_URI); + }); + + // Correction C3.2: no clientId => cannot start OAuth; throw a clear message. + it("throws a clear error when clientId is empty", () => { + const oauth = new SpotifyOAuth({ redirectUri: REDIRECT_URI, store: memStore() }); + expect(() => oauth.buildAuthorizeUrl()).toThrow(/Client ID/i); + }); +}); + +describe("SpotifyOAuth.isAuthorized (C3.2)", () => { + it("is false without a clientId even if a refresh token is stored", () => { + const store = memStore({ + accessToken: "a", + refreshToken: "r", + expiresAt: Date.now() + 60_000, + scope: "s", + }); + const oauth = new SpotifyOAuth({ redirectUri: REDIRECT_URI, store }); + expect(oauth.isAuthorized()).toBe(false); + }); + + it("is true with a clientId and a stored refresh token", () => { + const store = memStore({ + accessToken: "a", + refreshToken: "r", + expiresAt: Date.now() + 60_000, + scope: "s", + }); + const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store }); + expect(oauth.isAuthorized()).toBe(true); + }); +}); + +describe("SpotifyOAuth.handleCallback", () => { + it("exchanges the code (PKCE verifier matches the authorize challenge) and persists tokens", async () => { + const store = memStore(); + const http = { + post: vi.fn().mockResolvedValue({ + data: { + access_token: "a1", + refresh_token: "r1", + expires_in: 3600, + scope: SPOTIFY_CONTROL_SCOPES, + }, + }), + } as any; + const oauth = new SpotifyOAuth({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + store, + deps: { http }, + }); + + const { url, state } = oauth.buildAuthorizeUrl(); + const challenge = new URL(url).searchParams.get("code_challenge")!; + + const ok = await oauth.handleCallback("CODE123", state); + expect(ok).toBe(true); + + const [path, bodyStr, cfg] = http.post.mock.calls[0]; + expect(path).toBe("/api/token"); + expect(cfg.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + const body = new URLSearchParams(bodyStr as string); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("CODE123"); + expect(body.get("redirect_uri")).toBe(REDIRECT_URI); + expect(body.get("client_id")).toBe(CLIENT_ID); + // The verifier sent MUST hash to the challenge advertised in the authorize URL. + const verifier = body.get("code_verifier")!; + expect(codeChallengeS256(verifier)).toBe(challenge); + + expect(store.value?.accessToken).toBe("a1"); + expect(store.value?.refreshToken).toBe("r1"); + expect(store.value?.expiresAt).toBeGreaterThan(Date.now()); + expect(oauth.isAuthorized()).toBe(true); + }); + + it("rejects an unknown state without calling the token endpoint (CSRF guard)", async () => { + const http = { post: vi.fn() } as any; + const oauth = new SpotifyOAuth({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + store: memStore(), + deps: { http }, + }); + expect(await oauth.handleCallback("CODE", "not-a-real-state")).toBe(false); + expect(http.post).not.toHaveBeenCalled(); + }); + + // Correction C3.7: the state->verifier entry is deleted on EVERY terminal + // path (finally), so a failed login never leaks it and cannot be replayed. + it("deletes the pending verifier even when the token exchange fails", async () => { + const http = { + post: vi.fn().mockRejectedValue({ + response: { status: 400, data: { error: "invalid_grant" } }, + }), + } as any; + const oauth = new SpotifyOAuth({ + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + store: memStore(), + deps: { http }, + }); + const { state } = oauth.buildAuthorizeUrl(); + + // First attempt fails at the network/token step. + expect(await oauth.handleCallback("CODE", state)).toBe(false); + expect(http.post).toHaveBeenCalledTimes(1); + + // Replaying the same state now fails the CSRF guard (verifier was deleted), + // WITHOUT hitting the token endpoint again. + expect(await oauth.handleCallback("CODE", state)).toBe(false); + expect(http.post).toHaveBeenCalledTimes(1); + }); +}); + +describe("SpotifyOAuth.getAccessToken", () => { + it("returns the cached token without refreshing when still valid", async () => { + const http = { post: vi.fn() } as any; + const store = memStore({ + accessToken: "cached", + refreshToken: "r1", + expiresAt: Date.now() + 60_000, + scope: "s", + }); + const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } }); + expect(await oauth.getAccessToken()).toBe("cached"); + expect(http.post).not.toHaveBeenCalled(); + }); + + it("refreshes when expired and persists the ROTATED refresh token", async () => { + const store = memStore({ + accessToken: "old", + refreshToken: "r1", + expiresAt: Date.now() - 1000, + scope: "s", + }); + const http = { + post: vi.fn().mockResolvedValue({ + data: { access_token: "a2", refresh_token: "r2", expires_in: 3600 }, + }), + } as any; + const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } }); + + expect(await oauth.getAccessToken()).toBe("a2"); + const body = new URLSearchParams(http.post.mock.calls[0][1] as string); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("r1"); + expect(body.get("client_id")).toBe(CLIENT_ID); + expect(store.value?.refreshToken).toBe("r2"); // rotated + persisted + expect(store.value?.accessToken).toBe("a2"); + }); + + it("keeps the old refresh token when the refresh response omits a new one", async () => { + const store = memStore({ + accessToken: "old", + refreshToken: "r1", + expiresAt: Date.now() - 1000, + scope: "s", + }); + const http = { + post: vi.fn().mockResolvedValue({ data: { access_token: "a2", expires_in: 3600 } }), + } as any; + const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } }); + expect(await oauth.getAccessToken()).toBe("a2"); + expect(store.value?.refreshToken).toBe("r1"); + }); + + it("clears the store and returns null on invalid_grant (expired refresh token)", async () => { + const store = memStore({ + accessToken: "old", + refreshToken: "r1", + expiresAt: Date.now() - 1000, + scope: "s", + }); + const http = { + post: vi.fn().mockRejectedValue({ + response: { status: 400, data: { error: "invalid_grant" } }, + }), + } as any; + const oauth = new SpotifyOAuth({ clientId: CLIENT_ID, store, deps: { http } }); + expect(await oauth.getAccessToken()).toBeNull(); + expect(store.value).toBeNull(); + expect(oauth.isAuthorized()).toBe(false); + }); + + it("returns null when unauthorized (no stored refresh token)", async () => { + const http = { post: vi.fn() } as any; + const oauth = new SpotifyOAuth({ + clientId: CLIENT_ID, + store: memStore(), + deps: { http }, + }); + expect(await oauth.getAccessToken()).toBeNull(); + expect(oauth.isAuthorized()).toBe(false); + expect(http.post).not.toHaveBeenCalled(); + }); +}); + +describe("createFileOAuthTokenStore", () => { + it("round-trips save/load and clear() removes it", () => { + const dir = mkdtempSync(join(tmpdir(), "sp-oauth-")); + const file = join(dir, "nested", "tokens.json"); + try { + const store = createFileOAuthTokenStore(file); + expect(store.load()).toBeNull(); // missing file + const t: OAuthTokens = { + accessToken: "a", + refreshToken: "r", + expiresAt: 123, + scope: "s", + }; + store.save(t); + expect(store.load()).toEqual(t); + store.clear(); + expect(store.load()).toBeNull(); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/music/spotify/spotify-oauth.ts b/src/music/spotify/spotify-oauth.ts new file mode 100644 index 0000000..b77232c --- /dev/null +++ b/src/music/spotify/spotify-oauth.ts @@ -0,0 +1,225 @@ +import axios, { type AxiosInstance } from "axios"; +import { createHash, randomBytes } from "node:crypto"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; + +/** + * Player-control scopes requested for the USER token: streaming (drives + * librespot as a Connect device) + read/modify playback + currently-playing + + * private-playlist reads. + */ +export const SPOTIFY_CONTROL_SCOPES = + "streaming user-read-playback-state user-modify-playback-state user-read-currently-playing playlist-read-private"; + +const ACCOUNTS_BASE = "https://accounts.spotify.com"; +// Hand a token back only if it survives ~30s, matching webapi.ts's skew. +const EXPIRY_SKEW_MS = 30_000; +// RFC 7636 ยง4.1 unreserved set: [A-Za-z0-9-._~]. +const PKCE_CHARS = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; +const FORM_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" }; + +export interface OAuthTokens { + accessToken: string; + refreshToken: string; + expiresAt: number; + scope: string; +} + +export interface OAuthTokenStore { + load(): OAuthTokens | null; + save(t: OAuthTokens): void; + clear(): void; +} + +export interface SpotifyOAuthOptions { + /** + * Correction C3.2: the caller's OWN Spotify Developer app client_id. There is + * no librespot-public-client fallback โ€” an empty clientId disables OAuth. + */ + clientId?: string; + /** + * Loopback redirect registered on the caller's Spotify app, supplied by the + * bot's web layer (e.g. its `/api/spotify/callback`). Must exactly match the + * value used at both the authorize and token steps. + */ + redirectUri?: string; + store: OAuthTokenStore; + deps?: { http?: AxiosInstance }; +} + +/** 64 random chars from the PKCE unreserved set (43-128 allowed by the spec). */ +export function generateCodeVerifier(): string { + const bytes = randomBytes(64); + let out = ""; + for (let i = 0; i < 64; i++) out += PKCE_CHARS[bytes[i] % PKCE_CHARS.length]; + return out; +} + +/** base64url(SHA256(verifier)) with no padding โ€” the S256 code challenge. */ +export function codeChallengeS256(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +/** Persist OAuth tokens as a 0600 JSON file (used by the controller). */ +export function createFileOAuthTokenStore(filePath: string): OAuthTokenStore { + return { + load() { + try { + if (!existsSync(filePath)) return null; + const parsed = JSON.parse(readFileSync(filePath, "utf8")); + return parsed?.refreshToken ? (parsed as OAuthTokens) : null; + } catch { + return null; // missing/corrupt -> treat as unauthorized + } + }, + save(t: OAuthTokens) { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(t, null, 2), { mode: 0o600 }); + }, + clear() { + try { + rmSync(filePath, { force: true }); + } catch { + /* already gone */ + } + }, + }; +} + +/** + * Authorization Code + PKCE flow for the USER player-control token. Public + * client (no secret). + * + * Correction C3.2: this REQUIRES the operator's own registered Spotify app โ€” + * there is NO reuse of librespot's first-party keymaster client / fixed + * :5588 redirect. Without a clientId, `isAuthorized()` is false and + * `buildAuthorizeUrl()` throws. + * + * Refresh rotates the refresh token, so the newest is always persisted; + * invalid_grant clears the store (re-login required). Access/refresh tokens + * are never logged. + */ +export class SpotifyOAuth { + private clientId: string; + private redirectUri: string; + private store: OAuthTokenStore; + private http: AxiosInstance; + // Pending PKCE verifiers keyed by state, awaiting the loopback redirect back. + private pendingVerifiers = new Map(); + + constructor(o: SpotifyOAuthOptions) { + this.clientId = o.clientId ?? ""; + this.redirectUri = o.redirectUri ?? ""; + this.store = o.store; + this.http = + o.deps?.http ?? axios.create({ baseURL: ACCOUNTS_BASE, timeout: 15_000 }); + } + + getClientId(): string { + return this.clientId; + } + + getRedirectUri(): string { + return this.redirectUri; + } + + isAuthorized(): boolean { + // C3.2: no client_id means we could never refresh, so treat as unauthorized. + return !!this.clientId && !!this.store.load()?.refreshToken; + } + + buildAuthorizeUrl(): { url: string; state: string } { + if (!this.clientId) { + // C3.2: cannot start OAuth against nobody's app. + throw new Error("Set your Spotify Client ID in settings first"); + } + const state = randomBytes(16).toString("hex"); + const verifier = generateCodeVerifier(); + this.pendingVerifiers.set(state, verifier); + const params = new URLSearchParams({ + client_id: this.clientId, + response_type: "code", + redirect_uri: this.redirectUri, + code_challenge: codeChallengeS256(verifier), + code_challenge_method: "S256", + scope: SPOTIFY_CONTROL_SCOPES, + state, + }); + return { url: `${ACCOUNTS_BASE}/authorize?${params.toString()}`, state }; + } + + async handleCallback(code: string, state: string): Promise { + const verifier = this.pendingVerifiers.get(state); + if (!verifier) return false; // unknown/expired state -> CSRF guard + // C3.7: drop the state->verifier entry on EVERY terminal path (success, + // rejected token exchange, or throw) so a failed login can't leak/replay it. + try { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: this.redirectUri, + client_id: this.clientId, + code_verifier: verifier, + }); + const { data } = await this.http.post("/api/token", body.toString(), { + headers: FORM_HEADERS, + }); + if (!data?.access_token || !data?.refresh_token) return false; + this.store.save(this.toTokens(data, data.refresh_token, data.scope)); + return true; + } catch { + return false; + } finally { + this.pendingVerifiers.delete(state); + } + } + + async getAccessToken(): Promise { + if (!this.clientId) return null; // C3.2: no app => nothing to mint against + const tokens = this.store.load(); + if (!tokens?.refreshToken) return null; // unauthorized + if (tokens.accessToken && Date.now() < tokens.expiresAt) { + return tokens.accessToken; + } + return this.refresh(tokens); + } + + private async refresh(current: OAuthTokens): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: current.refreshToken, + client_id: this.clientId, + }); + try { + const { data } = await this.http.post("/api/token", body.toString(), { + headers: FORM_HEADERS, + }); + if (!data?.access_token) return null; + // PKCE rotates the refresh token; fall back to the current one if omitted. + const rotated = data.refresh_token || current.refreshToken; + const saved = this.toTokens(data, rotated, data.scope ?? current.scope); + this.store.save(saved); + return saved.accessToken; + } catch (err: any) { + // invalid_grant => refresh token revoked/expired: discard, force re-login. + if (err?.response?.data?.error === "invalid_grant") this.store.clear(); + return null; + } + } + + private toTokens(data: any, refreshToken: string, scope: string): OAuthTokens { + return { + accessToken: data.access_token, + refreshToken, + expiresAt: Date.now() + (data.expires_in ?? 3600) * 1000 - EXPIRY_SKEW_MS, + scope: scope ?? SPOTIFY_CONTROL_SCOPES, + }; + } +}