From e666bbe9ff508c1f72246e1e7c3969068f5c116a Mon Sep 17 00:00:00 2001 From: RTSDA Date: Wed, 20 Aug 2025 09:21:54 -0400 Subject: [PATCH] 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 --- Cargo.toml | 2 +- example-config.toml | 2 +- src/config.rs | 1 + src/main.rs | 14 ++-- src/models.rs | 2 +- src/server.rs | 177 ++++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 183 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1cf1562..69bbc23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/example-config.toml b/example-config.toml index 1216bef..f54aa51 100644 --- a/example-config.toml +++ b/example-config.toml @@ -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" diff --git a/src/config.rs b/src/config.rs index d142485..d509b26 100644 --- a/src/config.rs +++ b/src/config.rs @@ -30,6 +30,7 @@ impl Default for AppConfig { } } +#[derive(Clone)] pub struct ConfigManager { config_dir: PathBuf, config_file: PathBuf, diff --git a/src/main.rs b/src/main.rs index 626b4f9..de15a43 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 } \ No newline at end of file diff --git a/src/models.rs b/src/models.rs index 246a254..325eaa5 100644 --- a/src/models.rs +++ b/src/models.rs @@ -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(), diff --git a/src/server.rs b/src/server.rs index 8d60b67..06bb783 100644 --- a/src/server.rs +++ b/src/server.rs @@ -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, + config_manager: Arc, 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| { + 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::>()) + .map(move |params: HashMap| (client.clone(), config_manager.clone(), params)) + .and_then(|(client, config_manager, params): (Arc, Arc, HashMap)| 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) -> Result { } } +async fn handle_oauth_callback( + client: Arc, + config_manager: Arc, + params: HashMap, +) -> std::result::Result { + 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#" + + + + + Spotify Tracker - Authentication + + + +
+

🎵 Spotify Tracker Authentication

+

To use Spotify Tracker, you need to authorize access to your Spotify account.

+

Click the button below to authorize with Spotify. You'll be redirected back here after authentication.

+ Authorize with Spotify +

This will redirect you to Spotify's authorization page, then back to this site.

+
+ +"#, + auth_url + ) +} + +fn get_success_page() -> String { + r#" + + + + + Spotify Tracker - Success + + + +
+
+

Authentication Successful!

+

Your Spotify account has been successfully linked to Spotify Tracker.

+ +
+

Available Endpoints:

+
GET https://spotify.tougie.live/current - Current track (JSON)
+
GET https://spotify.tougie.live/phantombot - Current track (text)
+
GET https://spotify.tougie.live/health - Health check
+
+ +

You can now use the Spotify Tracker API to get your current playing track!

+
+ +"#.to_string() +} + +fn get_error_page(error: &str) -> String { + format!( + r#" + + + + + Spotify Tracker - Error + + + +
+
+

Authentication Failed

+

There was an error during authentication:

+

{}

+ Try Again +
+ +"#, + error + ) +} + #[cfg(test)] mod tests { use super::*;