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

View File

@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import {
buildAuthorizeUrl,
createCodeChallenge,
createCodeVerifier,
exchangeCodeForToken,
getAuthStatus,
refreshAccessToken,
refreshStoredToken,
waitForAuthorizationCode
} from "../src/auth.js";
import { loadToken, saveToken } from "../src/token-store.js";
import type { SpotifyConfig } from "../src/types.js";
const config: SpotifyConfig = {
clientId: "client-id",
redirectUri: "http://127.0.0.1:8888/callback"
};
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
server.close(() => {
if (!address || typeof address === "string") {
reject(new Error("Unable to reserve a test port."));
return;
}
resolve(address.port);
});
});
});
}
test("creates RFC7636 S256 code challenge", () => {
assert.equal(
createCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
});
test("creates url-safe verifier", () => {
const verifier = createCodeVerifier(randomBytes(64));
assert.match(verifier, /^[A-Za-z0-9_-]+$/);
});
test("builds authorize url with PKCE parameters and scopes", () => {
const url = new URL(buildAuthorizeUrl(config, "verifier", "state", ["playlist-read-private"]));
assert.equal(url.origin + url.pathname, "https://accounts.spotify.com/authorize");
assert.equal(url.searchParams.get("response_type"), "code");
assert.equal(url.searchParams.get("client_id"), "client-id");
assert.equal(url.searchParams.get("redirect_uri"), config.redirectUri);
assert.equal(url.searchParams.get("state"), "state");
assert.equal(url.searchParams.get("code_challenge_method"), "S256");
assert.equal(url.searchParams.get("scope"), "playlist-read-private");
});
test("exchanges code for token with PKCE body", async () => {
const calls: Array<{ url: string; body: URLSearchParams }> = [];
const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), body: init?.body as URLSearchParams });
return new Response(JSON.stringify({ access_token: "access", refresh_token: "refresh", expires_in: 10 }), { status: 200 });
};
const token = await exchangeCodeForToken(config, "code", "verifier", fetchImpl as typeof fetch, 1_000);
assert.deepEqual(token, { accessToken: "access", refreshToken: "refresh", expiresAt: 11_000 });
assert.equal(calls[0].url, "https://accounts.spotify.com/api/token");
assert.equal(calls[0].body.get("grant_type"), "authorization_code");
assert.equal(calls[0].body.get("code_verifier"), "verifier");
});
test("refresh preserves old refresh token when response omits one", async () => {
const fetchImpl = async () => new Response(JSON.stringify({ access_token: "new-access", expires_in: 10 }), { status: 200 });
const token = await refreshAccessToken(config, "old-refresh", fetchImpl as typeof fetch, 1_000);
assert.deepEqual(token, { accessToken: "new-access", refreshToken: "old-refresh", expiresAt: 11_000 });
});
test("refreshStoredToken persists refreshed token", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-auth-"));
await writeFile(join(root, "config.json"), JSON.stringify(config));
await saveToken({ accessToken: "old", refreshToken: "refresh", expiresAt: 1 }, { tokenPath: join(root, "token.json") });
const fetchImpl = async () => new Response(JSON.stringify({ access_token: "new", expires_in: 5 }), { status: 200 });
const token = await refreshStoredToken({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing", fetchImpl: fetchImpl as typeof fetch, now: () => 100 });
assert.deepEqual(token, { accessToken: "new", refreshToken: "refresh", expiresAt: 5_100 });
assert.deepEqual(await loadToken({ tokenPath: join(root, "token.json") }), token);
});
test("getAuthStatus reports config and token state without token values", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-auth-"));
await writeFile(join(root, "config.json"), JSON.stringify(config));
await saveToken({ accessToken: "secret-access", refreshToken: "secret-refresh", expiresAt: 200 }, { tokenPath: join(root, "token.json") });
const status = await getAuthStatus({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing", now: () => 100 });
assert.deepEqual(status, {
configFound: true,
tokenFound: true,
tokenExpired: false,
expiresAt: 200
});
assert.equal(JSON.stringify(status).includes("secret"), false);
});
test("authorization callback wait has a bounded timeout", async () => {
const port = await getFreePort();
await assert.rejects(
() => waitForAuthorizationCode(`http://127.0.0.1:${port}/callback`, "state", 1),
/timed out/
);
});