diff --git a/psst-core/src/oauth.rs b/psst-core/src/oauth.rs index 69331d54..e4dc6ba5 100644 --- a/psst-core/src/oauth.rs +++ b/psst-core/src/oauth.rs @@ -1,15 +1,16 @@ -use log::{debug, error, info, trace}; -use oauth2::reqwest::http_client; +use log::{debug, error, trace}; use oauth2::{ - basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, - RedirectUrl, Scope, TokenResponse, TokenUrl, + basic::BasicClient, reqwest::http_client, AuthUrl, AuthorizationCode, ClientId, CsrfToken, + PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, TokenResponse, TokenUrl, }; use std::io; use std::{ io::{BufRead, BufReader, Write}, + net::TcpStream, net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, process::exit, sync::mpsc, + time::Duration, }; use url::Url; @@ -31,40 +32,70 @@ fn get_authcode_stdin() -> AuthorizationCode { get_code(buffer.trim()) } -fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode { - let listener = TcpListener::bind(socket_address).unwrap(); +pub fn get_authcode_listener( + socket_address: SocketAddr, + timeout: Duration, +) -> Result { + log::info!("Starting OAuth listener on {:?}", socket_address); + let listener = TcpListener::bind(socket_address) + .map_err(|e| format!("Failed to bind to address: {}", e))?; + log::info!("Listener bound successfully"); - info!("OAuth server listening on {:?}", socket_address); + let (tx, rx) = mpsc::channel(); - let Some(mut stream) = listener.incoming().flatten().next() else { - panic!("listener terminated without accepting a connection"); - }; + let handle = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + handle_connection(&mut stream, &tx); + } + }); + + let result = rx + .recv_timeout(timeout) + .map_err(|_| "Timed out waiting for authorization code".to_string())?; - let mut reader = BufReader::new(&stream); + handle + .join() + .map_err(|_| "Failed to join server thread".to_string())?; + result +} + +fn handle_connection(stream: &mut TcpStream, tx: &mpsc::Sender>) { + let mut reader = BufReader::new(&mut *stream); let mut request_line = String::new(); - reader.read_line(&mut request_line).unwrap(); - let redirect_url = request_line.split_whitespace().nth(1).unwrap(); - let code = get_code(&("http://localhost".to_string() + redirect_url)); + if reader.read_line(&mut request_line).is_ok() { + if let Some(code) = extract_code_from_request(&request_line) { + send_success_response(stream); + let _ = tx.send(Ok(code)); + } else { + let _ = tx.send(Err("Failed to extract code from request".to_string())); + } + } +} - let message = "Authenticated! You can return to Psst."; - let response = format!( - "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", - message.len(), - message - ); - stream.write_all(response.as_bytes()).unwrap(); +fn extract_code_from_request(request_line: &str) -> Option { + request_line.split_whitespace().nth(1).and_then(|path| { + Url::parse(&format!("http://localhost{}", path)) + .ok()? + .query_pairs() + .find(|(key, _)| key == "code") + .map(|(_, code)| AuthorizationCode::new(code.into_owned())) + }) +} - code +fn send_success_response(stream: &mut TcpStream) { + let response = + "HTTP/1.1 200 OK\r\n\r\nYou can close this window now."; + let _ = stream.write_all(response.as_bytes()); } -pub fn get_access_token(client_id: &str, redirect_port: u16) -> String { +pub fn get_access_token(redirect_port: u16) -> String { let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port); let redirect_uri = format!("http://{redirect_address}/login"); let client = BasicClient::new( - ClientId::new(client_id.to_string()), + ClientId::new(crate::session::access_token::CLIENT_ID.to_string()), None, AuthUrl::new("https://accounts.spotify.com/authorize".to_string()).unwrap(), Some(TokenUrl::new("https://accounts.spotify.com/api/token".to_string()).unwrap()), @@ -73,35 +104,10 @@ pub fn get_access_token(client_id: &str, redirect_port: u16) -> String { let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); - let scopes = vec![ - "app-remote-control", - "playlist-modify", - "playlist-modify-private", - "playlist-modify-public", - "playlist-read", - "playlist-read-collaborative", - "playlist-read-private", - "streaming", - "ugc-image-upload", - "user-follow-modify", - "user-follow-read", - "user-library-modify", - "user-library-read", - "user-modify", - "user-modify-playback-state", - "user-modify-private", - "user-personalized", - "user-read-birthdate", - "user-read-currently-playing", - "user-read-email", - "user-read-play-history", - "user-read-playback-position", - "user-read-playback-state", - "user-read-private", - "user-read-recently-played", - "user-top-read", - ]; - let scopes: Vec = scopes.iter().map(|&s| Scope::new(s.into())).collect(); + let scopes: Vec = crate::session::access_token::ACCESS_SCOPES + .split(',') + .map(|s| Scope::new(s.trim().to_string())) + .collect(); let (auth_url, _) = client .authorize_url(CsrfToken::new_random) .add_scopes(scopes) @@ -111,7 +117,7 @@ pub fn get_access_token(client_id: &str, redirect_port: u16) -> String { println!("Browse to: {}", auth_url); let code = if redirect_port > 0 { - get_authcode_listener(redirect_address) + get_authcode_listener(redirect_address, Duration::from_secs(300)).unwrap() } else { get_authcode_stdin() }; @@ -140,3 +146,52 @@ pub fn get_access_token(client_id: &str, redirect_port: u16) -> String { token.access_token().secret().to_string() } + +fn create_spotify_oauth_client(redirect_port: u16) -> BasicClient { + let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port); + let redirect_uri = format!("http://{redirect_address}/login"); + + BasicClient::new( + ClientId::new(crate::session::access_token::CLIENT_ID.to_string()), + None, + AuthUrl::new("https://accounts.spotify.com/authorize".to_string()).unwrap(), + Some(TokenUrl::new("https://accounts.spotify.com/api/token".to_string()).unwrap()), + ) + .set_redirect_uri(RedirectUrl::new(redirect_uri).expect("Invalid redirect URL")) +} + +pub fn generate_auth_url(redirect_port: u16) -> (String, PkceCodeVerifier) { + let client = create_spotify_oauth_client(redirect_port); + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + let (auth_url, _) = client + .authorize_url(CsrfToken::new_random) + .add_scopes(get_scopes()) + .set_pkce_challenge(pkce_challenge) + .url(); + + (auth_url.to_string(), pkce_verifier) +} + +pub fn exchange_code_for_token( + redirect_port: u16, + code: AuthorizationCode, + pkce_verifier: PkceCodeVerifier, +) -> String { + let client = create_spotify_oauth_client(redirect_port); + + let token_response = client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request(http_client) + .expect("Failed to exchange code for token"); + + token_response.access_token().secret().to_string() +} + +fn get_scopes() -> Vec { + crate::session::access_token::ACCESS_SCOPES + .split(',') + .map(|s| Scope::new(s.trim().to_string())) + .collect() +} diff --git a/psst-core/src/session/access_token.rs b/psst-core/src/session/access_token.rs index c962fa4d..bd4dee61 100644 --- a/psst-core/src/session/access_token.rs +++ b/psst-core/src/session/access_token.rs @@ -8,10 +8,10 @@ use crate::error::Error; use super::SessionService; // Client ID of the official Web Spotify front-end. -const CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; +pub const CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; // All scopes we could possibly require. -const ACCESS_SCOPES: &str = "streaming,user-read-email,user-read-private,playlist-read-private,playlist-read-collaborative,playlist-modify-public,playlist-modify-private,user-follow-modify,user-follow-read,user-library-read,user-library-modify,user-top-read,user-read-recently-played"; +pub const ACCESS_SCOPES: &str = "streaming,user-read-email,user-read-private,playlist-read-private,playlist-read-collaborative,playlist-modify-public,playlist-modify-private,user-follow-modify,user-follow-read,user-library-read,user-library-modify,user-top-read,user-read-recently-played"; // Consider token expired even before the official expiration time. Spotify // seems to be reporting excessive token TTLs so let's cut it down by 30 diff --git a/psst-core/src/session/mod.rs b/psst-core/src/session/mod.rs index 07ea0813..8204470e 100644 --- a/psst-core/src/session/mod.rs +++ b/psst-core/src/session/mod.rs @@ -38,7 +38,6 @@ use self::{ pub struct SessionConfig { pub login_creds: Credentials, pub proxy_url: Option, - pub client_id: String, } /// Cheap to clone, shareable service handle that holds the active session diff --git a/psst-gui/src/data/config.rs b/psst-gui/src/data/config.rs index e2b3b975..08c2aea5 100644 --- a/psst-gui/src/data/config.rs +++ b/psst-gui/src/data/config.rs @@ -65,7 +65,6 @@ impl Authentication { ) }, proxy_url: Config::proxy(), - client_id: Config::default().client_id, } } @@ -101,7 +100,6 @@ pub struct Config { pub sort_criteria: SortCriteria, pub paginated_limit: usize, pub seek_duration: usize, - pub client_id: String, } impl Default for Config { @@ -120,8 +118,6 @@ impl Default for Config { sort_criteria: Default::default(), paginated_limit: 500, seek_duration: 10, - // Default Spotify Desktop client ID - client_id: "65b708073fc0480ea92a077233ca87bd".to_string(), } } } @@ -202,7 +198,6 @@ impl Config { SessionConfig { login_creds: self.credentials.clone().expect("Missing credentials"), proxy_url: Config::proxy(), - client_id: self.client_id.clone(), } } diff --git a/psst-gui/src/ui/mod.rs b/psst-gui/src/ui/mod.rs index f51189dc..0f7b9b6b 100644 --- a/psst-gui/src/ui/mod.rs +++ b/psst-gui/src/ui/mod.rs @@ -81,7 +81,7 @@ pub fn preferences_window() -> WindowDesc { pub fn account_setup_window() -> WindowDesc { let win = WindowDesc::new(account_setup_widget()) - .title("Log In") + .title("Login") .window_size((theme::grid(50.0), theme::grid(45.0))) .resizable(false) .show_title(false) diff --git a/psst-gui/src/ui/preferences.rs b/psst-gui/src/ui/preferences.rs index 27dca868..ec87e00c 100644 --- a/psst-gui/src/ui/preferences.rs +++ b/psst-gui/src/ui/preferences.rs @@ -1,3 +1,4 @@ +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::thread::{self, JoinHandle}; use crate::{ @@ -19,8 +20,7 @@ use druid::{ Color, Data, Env, Event, EventCtx, Insets, LensExt, LifeCycle, LifeCycleCtx, Selector, Widget, WidgetExt, }; -use psst_core::connection::Credentials; -use psst_core::oauth; +use psst_core::{connection::Credentials, oauth, session::SessionConfig}; use super::{icons::SvgIcon, theme}; @@ -270,17 +270,15 @@ fn account_tab_widget(tab: AccountTab) -> impl Widget { col = col .with_child( - Button::new("Log In with Spotify").on_click(|ctx, data: &mut AppState, _| { - let token = oauth::get_access_token(&data.config.client_id, 8888); - data.preferences.auth.access_token = token; + Button::new("Log in with Spotify").on_click(|ctx, _data: &mut AppState, _| { ctx.submit_command(Authenticate::REQUEST); }), ) .with_spacer(theme::grid(1.0)) .with_child( Async::new( - || Label::new("Logging In...").with_text_size(theme::TEXT_SIZE_SMALL), - || Label::new("Success.").with_text_size(theme::TEXT_SIZE_SMALL), + || Label::new("Logging in...").with_text_size(theme::TEXT_SIZE_SMALL), + || Label::new("").with_text_size(theme::TEXT_SIZE_SMALL), || { Label::dynamic(|err: &String, _| err.to_owned()) .with_text_size(theme::TEXT_SIZE_SMALL) @@ -294,8 +292,6 @@ fn account_tab_widget(tab: AccountTab) -> impl Widget { ), ); - col = col.with_spacer(theme::grid(3.0)); - if matches!(tab, AccountTab::InPreferences) { col = col.with_child(Button::new("Log Out").on_left_click(|ctx, _, _, _| { ctx.submit_command(cmd::LOG_OUT); @@ -335,17 +331,39 @@ impl> Controller for Authenticate { Event::Command(cmd) if cmd.is(Self::REQUEST) => { data.preferences.auth.result.defer_default(); + let (auth_url, pkce_verifier) = oauth::generate_auth_url(8888); + if webbrowser::open(&auth_url).is_err() { + data.error_alert("Failed to open browser"); + return; + } + let config = data.preferences.auth.session_config(); let widget_id = ctx.widget_id(); let event_sink = ctx.get_external_handle(); let thread = thread::spawn(move || { - let response = Authentication::authenticate_and_get_credentials(config); - event_sink - .submit_command(Self::RESPONSE, response, widget_id) - .unwrap(); + match oauth::get_authcode_listener( + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8888), + std::time::Duration::from_secs(300), + ) { + Ok(code) => { + let token = oauth::exchange_code_for_token(8888, code, pkce_verifier); + let response = + Authentication::authenticate_and_get_credentials(SessionConfig { + login_creds: Credentials::from_access_token(token), + ..config + }); + event_sink + .submit_command(Self::RESPONSE, response, widget_id) + .unwrap(); + } + Err(e) => { + event_sink + .submit_command(Self::RESPONSE, Err(e), widget_id) + .unwrap(); + } + } }); self.thread.replace(thread); - ctx.set_handled(); } Event::Command(cmd) if cmd.is(Self::RESPONSE) => {