Add OAuth2 web interface for automated Spotify authentication

- Add /auth endpoint with user-friendly web form
- Add OAuth callback handler that automatically exchanges codes for tokens
- Update redirect URI to spotify.tougie.live subdomain
- Add success/error pages for authentication flow
- Switch to rustls-only for better cross-platform compatibility
- Update server endpoints to show spotify.tougie.live URLs
- Remove manual code entry requirement
This commit is contained in:
RTSDA 2025-08-20 09:21:54 -04:00
parent bcfa2ba1c2
commit e666bbe9ff
6 changed files with 183 additions and 15 deletions

View file

@ -5,7 +5,7 @@ edition = "2021"
[dependencies]
tokio = { version = "1.0", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
base64 = "0.22"

View file

@ -4,7 +4,7 @@
[spotify]
client_id = "your_spotify_client_id_here"
client_secret = "your_spotify_client_secret_here"
redirect_uri = "http://localhost:8888/callback"
redirect_uri = "https://spotify.tougie.live"
scopes = ["user-read-currently-playing", "user-read-playback-state"]
# Output format: "json", "plain", or "formatted"

View file

@ -30,6 +30,7 @@ impl Default for AppConfig {
}
}
#[derive(Clone)]
pub struct ConfigManager {
config_dir: PathBuf,
config_file: PathBuf,

View file

@ -390,14 +390,14 @@ async fn handle_server(
config: &AppConfig,
port: u16,
) -> Result<()> {
let token_info = config_manager.load_token()?
.ok_or_else(|| SpotifyError::AuthError {
message: "Not authenticated. Run 'spotify-tracker auth' first.".to_string(),
})?;
// Don't require authentication for server mode - it will handle OAuth flow
let client = SpotifyClient::new(config.spotify.clone());
client.set_token(token_info).await;
// If we have a token, set it
if let Ok(Some(token_info)) = config_manager.load_token() {
client.set_token(token_info).await;
}
let server = SpotifyServer::new(client, port);
let server = SpotifyServer::new(client, config_manager.clone(), port);
server.start().await
}

View file

@ -14,7 +14,7 @@ impl Default for SpotifyConfig {
Self {
client_id: String::new(),
client_secret: String::new(),
redirect_uri: "http://localhost:8888/callback".to_string(),
redirect_uri: "https://spotify.tougie.live".to_string(),
scopes: vec![
"user-read-currently-playing".to_string(),
"user-read-playback-state".to_string(),

View file

@ -1,18 +1,21 @@
use crate::{Result, SpotifyClient, SpotifyError};
use crate::{ConfigManager, Result, SpotifyClient, SpotifyError};
use serde_json::json;
use std::collections::HashMap;
use std::convert::Infallible;
use std::sync::Arc;
use warp::Filter;
pub struct SpotifyServer {
client: Arc<SpotifyClient>,
config_manager: Arc<ConfigManager>,
port: u16,
}
impl SpotifyServer {
pub fn new(client: SpotifyClient, port: u16) -> Self {
pub fn new(client: SpotifyClient, config_manager: ConfigManager, port: u16) -> Self {
Self {
client: Arc::new(client),
config_manager: Arc::new(config_manager),
port,
}
}
@ -21,6 +24,7 @@ impl SpotifyServer {
log::info!("Starting Spotify Tracker server on port {}", self.port);
let client = self.client.clone();
let config_manager = self.config_manager.clone();
// Current track endpoint - mimics your existing API
let current_track = {
@ -68,6 +72,31 @@ impl SpotifyServer {
})
};
// OAuth authorization start endpoint
let auth_start = {
let client = client.clone();
warp::path("auth")
.and(warp::get())
.map(move || client.clone())
.map(|client: Arc<SpotifyClient>| {
let auth_url = client.get_authorization_url(Some("spotify-tracker"));
warp::reply::html(get_auth_page(&auth_url))
})
};
// OAuth callback endpoint
let oauth_callback = {
let client = client.clone();
let config_manager = config_manager.clone();
warp::path::end()
.and(warp::get())
.and(warp::query::<HashMap<String, String>>())
.map(move |params: HashMap<String, String>| (client.clone(), config_manager.clone(), params))
.and_then(|(client, config_manager, params): (Arc<SpotifyClient>, Arc<ConfigManager>, HashMap<String, String>)| async move {
handle_oauth_callback(client, config_manager, params).await
})
};
// Health check endpoint
let health = warp::path("health")
.and(warp::get())
@ -87,15 +116,18 @@ impl SpotifyServer {
let routes = current_track
.or(phantombot)
.or(auth_start)
.or(oauth_callback)
.or(health)
.with(cors)
.with(warp::log("spotify_tracker"));
println!("🎵 Spotify Tracker Server Running!");
println!("================================");
println!("Current track (JSON): http://localhost:{}/current", self.port);
println!("PhantomBot format: http://localhost:{}/phantombot", self.port);
println!("Health check: http://localhost:{}/health", self.port);
println!("OAuth Setup: https://spotify.tougie.live/auth", );
println!("Current track (JSON): https://spotify.tougie.live/current");
println!("PhantomBot format: https://spotify.tougie.live/phantombot");
println!("Health check: https://spotify.tougie.live/health");
println!();
println!("Press Ctrl+C to stop");
@ -141,6 +173,141 @@ async fn get_current_track_text(client: Arc<SpotifyClient>) -> Result<String> {
}
}
async fn handle_oauth_callback(
client: Arc<SpotifyClient>,
config_manager: Arc<ConfigManager>,
params: HashMap<String, String>,
) -> std::result::Result<impl warp::Reply, std::convert::Infallible> {
if let Some(code) = params.get("code") {
// Exchange code for token
match client.exchange_code(code).await {
Ok(token_info) => {
// Save token
match config_manager.save_token(&token_info) {
Ok(()) => {
log::info!("OAuth authentication successful");
Ok(warp::reply::html(get_success_page()))
}
Err(e) => {
log::error!("Failed to save token: {}", e);
Ok(warp::reply::html(get_error_page(&format!("Failed to save token: {}", e))))
}
}
}
Err(e) => {
log::error!("OAuth token exchange failed: {}", e);
Ok(warp::reply::html(get_error_page(&format!("Authentication failed: {}", e))))
}
}
} else if let Some(error) = params.get("error") {
let error_description = params.get("error_description").unwrap_or(error);
log::error!("OAuth error: {}", error_description);
Ok(warp::reply::html(get_error_page(&format!("Authentication error: {}", error_description))))
} else {
log::warn!("OAuth callback received with no code or error");
Ok(warp::reply::html(get_error_page("Invalid callback parameters")))
}
}
fn get_auth_page(auth_url: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spotify Tracker - Authentication</title>
<style>
body {{ font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background-color: #f5f5f5; }}
.container {{ background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
.spotify-btn {{ background: #1DB954; color: white; padding: 15px 30px; border: none; border-radius: 50px; font-size: 16px; text-decoration: none; display: inline-block; margin: 20px 0; }}
.spotify-btn:hover {{ background: #1ed760; }}
h1 {{ color: #333; }}
p {{ color: #666; line-height: 1.6; }}
</style>
</head>
<body>
<div class="container">
<h1>🎵 Spotify Tracker Authentication</h1>
<p>To use Spotify Tracker, you need to authorize access to your Spotify account.</p>
<p>Click the button below to authorize with Spotify. You'll be redirected back here after authentication.</p>
<a href="{}" class="spotify-btn">Authorize with Spotify</a>
<p><small>This will redirect you to Spotify's authorization page, then back to this site.</small></p>
</div>
</body>
</html>"#,
auth_url
)
}
fn get_success_page() -> String {
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spotify Tracker - Success</title>
<style>
body { font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background-color: #f5f5f5; }
.container { background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); text-align: center; }
.success { color: #1DB954; font-size: 48px; margin-bottom: 20px; }
h1 { color: #333; }
p { color: #666; line-height: 1.6; }
.api-endpoints { background: #f8f9fa; padding: 20px; border-radius: 5px; margin: 20px 0; text-align: left; }
.endpoint { font-family: monospace; background: #e9ecef; padding: 5px 10px; border-radius: 3px; margin: 5px 0; }
</style>
</head>
<body>
<div class="container">
<div class="success"></div>
<h1>Authentication Successful!</h1>
<p>Your Spotify account has been successfully linked to Spotify Tracker.</p>
<div class="api-endpoints">
<h3>Available Endpoints:</h3>
<div class="endpoint">GET https://spotify.tougie.live/current - Current track (JSON)</div>
<div class="endpoint">GET https://spotify.tougie.live/phantombot - Current track (text)</div>
<div class="endpoint">GET https://spotify.tougie.live/health - Health check</div>
</div>
<p>You can now use the Spotify Tracker API to get your current playing track!</p>
</div>
</body>
</html>"#.to_string()
}
fn get_error_page(error: &str) -> String {
format!(
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spotify Tracker - Error</title>
<style>
body {{ font-family: Arial, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; background-color: #f5f5f5; }}
.container {{ background: white; padding: 30px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); text-align: center; }}
.error {{ color: #dc3545; font-size: 48px; margin-bottom: 20px; }}
h1 {{ color: #333; }}
p {{ color: #666; line-height: 1.6; }}
.retry-btn {{ background: #1DB954; color: white; padding: 15px 30px; border: none; border-radius: 50px; font-size: 16px; text-decoration: none; display: inline-block; margin: 20px 0; }}
.retry-btn:hover {{ background: #1ed760; }}
</style>
</head>
<body>
<div class="container">
<div class="error"></div>
<h1>Authentication Failed</h1>
<p>There was an error during authentication:</p>
<p><strong>{}</strong></p>
<a href="/auth" class="retry-btn">Try Again</a>
</div>
</body>
</html>"#,
error
)
}
#[cfg(test)]
mod tests {
use super::*;