auth.ts•9.39 kB
import http from "http";
import fs from "fs/promises";
import { chmodSync } from "fs";
import type {
TokenData,
TokenStorage,
AuthCallbackParams,
} from "./types.js";
import {
generateState,
getTokensPath,
ensureConfigDir,
openBrowser,
} from "./utils.js";
import type { Logger } from "./logger.js";
const SPOTIFY_AUTH_URL = "https://accounts.spotify.com/authorize";
const SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token";
const SCOPES = [
"user-read-private",
"user-read-playback-state",
"user-modify-playback-state",
"user-read-recently-played", // For recently played tracks
"playlist-read-private",
"playlist-modify-public",
"playlist-modify-private",
"user-library-read",
"user-library-modify",
];
export class AuthManager {
private clientId: string;
private clientSecret: string;
private redirectUri: string;
private logger: Logger;
private tokenData: TokenData | null = null;
private state: string | null = null;
constructor(
clientId: string,
clientSecret: string,
redirectUri: string,
logger: Logger
) {
this.clientId = clientId;
this.clientSecret = clientSecret;
this.redirectUri = redirectUri;
this.logger = logger;
}
/**
* Initialize auth manager - load existing tokens if available
*/
async initialize(): Promise<void> {
await ensureConfigDir();
await this.loadTokens();
if (this.tokenData) {
await this.logger.info("Loaded existing tokens from storage");
// Check if token needs refresh
if (this.isTokenExpired()) {
await this.logger.info("Token expired, refreshing...");
await this.refreshAccessToken();
}
} else {
await this.logger.info("No existing tokens found, authorization required");
}
}
/**
* Check if we have a valid access token
*/
hasValidToken(): boolean {
return this.tokenData !== null && !this.isTokenExpired();
}
/**
* Get the current access token (refresh if needed)
*/
async getAccessToken(): Promise<string> {
if (!this.tokenData) {
throw new Error("No token available. Please authorize first.");
}
if (this.isTokenExpired()) {
await this.logger.info("Token expired, refreshing...");
await this.refreshAccessToken();
}
return this.tokenData.access_token;
}
/**
* Start the OAuth authorization flow
*/
async authorize(): Promise<void> {
this.state = generateState();
const authUrl = this.buildAuthUrl();
await this.logger.info("Starting OAuth authorization flow");
await this.logger.info(`Opening browser to: ${authUrl}`);
// Start local callback server
const code = await this.startCallbackServer();
// Exchange code for tokens
await this.exchangeCodeForTokens(code);
await this.logger.info("Authorization successful");
}
/**
* Build the Spotify authorization URL
*/
private buildAuthUrl(): string {
if (!this.state) {
throw new Error("State must be generated before building auth URL");
}
const params = new URLSearchParams({
client_id: this.clientId,
response_type: "code",
redirect_uri: this.redirectUri,
state: this.state,
scope: SCOPES.join(" "),
});
return `${SPOTIFY_AUTH_URL}?${params.toString()}`;
}
/**
* Start local HTTP server to receive OAuth callback
*/
private async startCallbackServer(): Promise<string> {
return new Promise((resolve, reject) => {
const server = http.createServer(async (req, res) => {
const url = new URL(req.url || "", `http://localhost`);
if (url.pathname === "/callback") {
const params: AuthCallbackParams = {
code: url.searchParams.get("code") || undefined,
error: url.searchParams.get("error") || undefined,
state: url.searchParams.get("state") || undefined,
};
if (params.error) {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(`<h1>Authorization failed</h1><p>Error: ${params.error}</p>`);
server.close();
reject(new Error(`Authorization failed: ${params.error}`));
return;
}
if (!params.code) {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(`<h1>Authorization failed</h1><p>No authorization code received</p>`);
server.close();
reject(new Error("No authorization code received"));
return;
}
if (params.state !== this.state) {
res.writeHead(400, { "Content-Type": "text/html" });
res.end(`<h1>Authorization failed</h1><p>State mismatch</p>`);
server.close();
reject(new Error("State mismatch"));
return;
}
res.writeHead(200, { "Content-Type": "text/html" });
res.end(`
<h1>Authorization successful!</h1>
<p>You can close this window and return to your terminal.</p>
<script>window.close();</script>
`);
server.close();
resolve(params.code);
}
});
const port = new URL(this.redirectUri).port || "15732";
server.listen(parseInt(port), () => {
this.logger.info(`Callback server listening on port ${port}`);
// Open browser after server is ready
const authUrl = this.buildAuthUrl();
openBrowser(authUrl).catch((err) => {
this.logger.error(`Failed to open browser: ${err.message}`);
});
});
server.on("error", (err) => {
reject(err);
});
});
}
/**
* Exchange authorization code for access and refresh tokens
*/
private async exchangeCodeForTokens(code: string): Promise<void> {
const params = new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: "authorization_code",
code,
redirect_uri: this.redirectUri,
});
await this.logger.info("Exchanging authorization code for tokens");
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${response.status} ${errorText}`);
}
const data = await response.json();
this.tokenData = {
access_token: data.access_token,
token_type: data.token_type,
expires_in: data.expires_in,
refresh_token: data.refresh_token,
scope: data.scope,
expires_at: Date.now() + data.expires_in * 1000,
};
await this.saveTokens();
await this.logger.info("Tokens saved successfully");
}
/**
* Refresh the access token using the refresh token
*/
private async refreshAccessToken(): Promise<void> {
if (!this.tokenData?.refresh_token) {
throw new Error("No refresh token available");
}
const params = new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
grant_type: "refresh_token",
refresh_token: this.tokenData.refresh_token,
});
await this.logger.info("Refreshing access token");
const response = await fetch(SPOTIFY_TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: params.toString(),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${errorText}`);
}
const data = await response.json();
this.tokenData = {
...this.tokenData,
access_token: data.access_token,
expires_in: data.expires_in,
expires_at: Date.now() + data.expires_in * 1000,
// Keep existing refresh_token if new one not provided
refresh_token: data.refresh_token || this.tokenData.refresh_token,
scope: data.scope || this.tokenData.scope,
};
await this.saveTokens();
await this.logger.info("Access token refreshed successfully");
}
/**
* Check if the current token is expired (with 5-minute buffer)
*/
private isTokenExpired(): boolean {
if (!this.tokenData) return true;
const bufferMs = 5 * 60 * 1000; // 5 minutes
return Date.now() >= this.tokenData.expires_at - bufferMs;
}
/**
* Load tokens from storage
*/
private async loadTokens(): Promise<void> {
try {
const tokensPath = getTokensPath();
const data = await fs.readFile(tokensPath, "utf-8");
const storage: TokenStorage = JSON.parse(data);
this.tokenData = storage.token_data || null;
} catch (error) {
// File doesn't exist or is invalid
this.tokenData = null;
}
}
/**
* Save tokens to storage
*/
private async saveTokens(): Promise<void> {
const tokensPath = getTokensPath();
const storage: TokenStorage = {
token_data: this.tokenData || undefined,
};
await fs.writeFile(tokensPath, JSON.stringify(storage, null, 2), "utf-8");
// Set file permissions to 0600 (owner read/write only)
try {
chmodSync(tokensPath, 0o600);
} catch (error) {
await this.logger.error(`Failed to set token file permissions: ${error}`);
}
}
}