mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
feat(spotify): add SpotifyOAuth Authorization Code + PKCE control-token flow
Stage 3 Task 2. PKCE (S256) authorize URL, code exchange, and refresh with rotated-refresh-token persistence + invalid_grant store-clear. axios/http and token store injected for fully mocked, network-free unit tests. Corrections C3.2 (require the operator's own client_id; no librespot public client / :5588 default; buildAuthorizeUrl throws + isAuthorized false without it) and C3.7 (delete the state->verifier map entry in a finally on every terminal path) applied. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8c84090b63
commit
333f7606e4
2 files changed
+522
No files matched your search
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>();
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user