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