From 8d1a93288e088346955f2f0ef7fc97683c97da62 Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Fri, 24 Jan 2025 19:17:01 +0100 Subject: [PATCH 01/21] get refresh token. Optional auth url browser opening --- Cargo.lock | 37 ++++++++++++++++++++++++ oauth/Cargo.toml | 1 + oauth/examples/oauth.rs | 2 +- oauth/src/lib.rs | 62 +++++++++++++++++++++++++++++++++++++++-- src/main.rs | 1 + 5 files changed, 100 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 51bf7fd15..6fb102dc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1729,6 +1729,25 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -2124,6 +2143,7 @@ dependencies = [ "env_logger", "log", "oauth2", + "open", "thiserror 2.0.11", "url", ] @@ -2533,6 +2553,17 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "open" +version = "5.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl-probe" version = "0.1.5" @@ -2596,6 +2627,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "pbkdf2" version = "0.12.2" diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml index c3d4a81b1..c27aae08b 100644 --- a/oauth/Cargo.toml +++ b/oauth/Cargo.toml @@ -11,6 +11,7 @@ edition = "2021" [dependencies] log = "0.4" oauth2 = "4.4" +open = "5.3.2" thiserror = "2.0" url = "2.2" diff --git a/oauth/examples/oauth.rs b/oauth/examples/oauth.rs index 76ff088e3..10e914157 100644 --- a/oauth/examples/oauth.rs +++ b/oauth/examples/oauth.rs @@ -25,7 +25,7 @@ fn main() { return; }; - match get_access_token(client_id, redirect_uri, scopes) { + match get_access_token(client_id, redirect_uri, scopes, false) { Ok(token) => println!("Success: {token:#?}"), Err(e) => println!("Failed: {e}"), }; diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 591e65594..e5fb151c4 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -12,6 +12,7 @@ use log::{error, info, trace}; use oauth2::reqwest::http_client; +use oauth2::RefreshToken; use oauth2::{ basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenResponse, TokenUrl, @@ -163,11 +164,14 @@ fn get_socket_address(redirect_uri: &str) -> Option { } /// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. -/// The redirect_uri must match what is registered to the client ID. +/// The `redirect_uri` must match what is registered to the client ID. +/// Set `open_auth_url` to true if you want your default browser to open the auth url automatically, +/// instead of printing it to standard output. pub fn get_access_token( client_id: &str, redirect_uri: &str, scopes: Vec<&str>, + open_auth_url: bool, ) -> Result { let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) .map_err(|_| OAuthError::InvalidSpotifyUri)?; @@ -201,7 +205,11 @@ pub fn get_access_token( .set_pkce_challenge(pkce_challenge) .url(); - println!("Browse to: {}", auth_url); + if open_auth_url { + open::that_in_background(auth_url.as_str()); + } else { + println!("{}", auth_url); + } let code = match get_socket_address(redirect_uri) { Some(addr) => get_authcode_listener(addr), @@ -244,6 +252,56 @@ pub fn get_access_token( }) } +pub fn get_refresh_token( + client_id: &str, + refresh_token: &str, + redirect_uri: &str, +) -> Result { + let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let token_url = TokenUrl::new("https://accounts.spotify.com/api/token".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let redirect_url = + RedirectUrl::new(redirect_uri.to_string()).map_err(|e| OAuthError::InvalidRedirectUri { + uri: redirect_uri.to_string(), + e, + })?; + let client = BasicClient::new( + ClientId::new(client_id.to_string()), + None, + auth_url, + Some(token_url), + ) + .set_redirect_uri(redirect_url); + + let refresh_token = RefreshToken::new(refresh_token.to_string()); + let resp = client + .exchange_refresh_token(&refresh_token) + .request(http_client); + let token = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + trace!("Obtained new access token: {token:?}"); + + let token_scopes: Vec = token + .scopes() + .map(|s| s.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + let refresh_token = token + .refresh_token() + .map_or(String::new(), |t| t.secret().to_string()); + + Ok(OAuthToken { + access_token: token.access_token().secret().to_string(), + refresh_token, + expires_at: Instant::now() + + token + .expires_in() + .unwrap_or_else(|| Duration::from_secs(3600)), + token_type: format!("{:?}", token.token_type()).to_string(), + scopes: token_scopes, + }) +} + #[cfg(test)] mod test { use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; diff --git a/src/main.rs b/src/main.rs index 2d6c96149..c6c2419cc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1899,6 +1899,7 @@ async fn main() { &setup.session_config.client_id, &format!("http://127.0.0.1{port_str}/login"), OAUTH_SCOPES.to_vec(), + false, ) { Ok(token) => token.access_token, Err(e) => { From af0bdadac673f34883d7b4e324d3e7b956e21074 Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Fri, 24 Jan 2025 19:21:11 +0100 Subject: [PATCH 02/21] changelog --- CHANGELOG.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 305bf5d5a..563e272c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking) - [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking) - [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient` +- [oauth] Add `open_auth_url` parameter to `get_access_token` (breaking) +- [oauth] Add `refresh_token()` to obtain a new access token from an existing refresh token ### Fixed @@ -75,7 +77,7 @@ backend for Spotify Connect discovery. ## [0.5.0] - 2024-10-15 This version is be a major departure from the architecture up until now. It -focuses on implementing the "new Spotify API". This means moving large parts +focuses on implementing the "new Spotify API". This means moving large parts of the Spotify protocol from Mercury to HTTP. A lot of this was reverse engineered before by @devgianlu of librespot-java. It was long overdue that we started implementing it too, not in the least because new features like the @@ -218,14 +220,17 @@ to offer. But, unless anything big comes up, it is also intended as the last release to be based on the old API. Happy listening. ### Changed + - [playback] `pipe`: Better error handling - [playback] `subprocess`: Better error handling ### Added + - [core] `apresolve`: Blacklist ap-gew4 and ap-gue1 access points that cause channel errors - [playback] `pipe`: Implement stop ### Fixed + - [main] fix `--opt=value` line argument logging - [playback] `alsamixer`: make `--volume-ctrl fixed` work as expected when combined with `--mixer alsa` @@ -234,9 +239,11 @@ release to be based on the old API. Happy listening. This release fixes dependency issues when installing from crates. ### Changed + - [chore] The MSRV is now 1.56 ### Fixed + - [playback] Fixed dependency issues when installing from crate ## [0.4.0] - 2022-05-21 @@ -252,6 +259,7 @@ Targeting that major effort for a v0.5 release sometime, we intend to maintain v0.4.x as a stable branch until then. ### Changed + - [chore] The MSRV is now 1.53 - [contrib] Hardened security of the `systemd` service units - [core] `Session`: `connect()` now returns the long-term credentials @@ -264,6 +272,7 @@ v0.4.x as a stable branch until then. - [playback] `Sink`: `write()` now receives ownership of the packet (breaking) ### Added + - [main] Enforce reasonable ranges for option values (breaking) - [main] Add the ability to parse environment variables - [main] Log now emits warning when trying to use options that would otherwise have no effect @@ -276,6 +285,7 @@ v0.4.x as a stable branch until then. - [playback] `pulseaudio`: set values to: `PULSE_PROP_application.version`, `PULSE_PROP_application.process.binary`, `PULSE_PROP_stream.description`, `PULSE_PROP_media.software` and `PULSE_PROP_media.role` environment variables (user set env var values take precedence) (breaking) ### Fixed + - [connect] Don't panic when activating shuffle without previous interaction - [core] Removed unsafe code (breaking) - [main] Fix crash when built with Avahi support but Avahi is locally unavailable @@ -286,20 +296,24 @@ v0.4.x as a stable branch until then. - [playback] `alsa`: make `--volume-range` overrides apply to Alsa softvol controls ### Removed + - [playback] `alsamixer`: previously deprecated options `mixer-card`, `mixer-name` and `mixer-index` have been removed ## [0.3.1] - 2021-10-24 ### Changed + - Include build profile in the displayed version information - [playback] Improve dithering CPU usage by about 33% ### Fixed + - [connect] Partly fix behavior after last track of an album/playlist ## [0.3.0] - 2021-10-13 ### Added + - [discovery] The crate `librespot-discovery` for discovery in LAN was created. Its functionality was previously part of `librespot-connect`. - [playback] Add support for dithering with `--dither` for lower requantization error (breaking) - [playback] Add `--volume-range` option to set dB range and control `log` and `cubic` volume control curves @@ -308,6 +322,7 @@ v0.4.x as a stable branch until then. - [playback] Add `--normalisation-gain-type auto` that switches between album and track automatically ### Changed + - [audio, playback] Moved `VorbisDecoder`, `VorbisError`, `AudioPacket`, `PassthroughDecoder`, `PassthroughError`, `DecoderError`, `AudioDecoder` and the `convert` module from `librespot-audio` to `librespot-playback`. The underlying crates `vorbis`, `librespot-tremor`, `lewton` and `ogg` should be used directly. (breaking) - [audio, playback] Use `Duration` for time constants and functions (breaking) - [connect, playback] Moved volume controls from `librespot-connect` to `librespot-playback` crate @@ -324,17 +339,20 @@ v0.4.x as a stable branch until then. - [playback] `player`: default normalisation type is now `auto` ### Deprecated + - [connect] The `discovery` module was deprecated in favor of the `librespot-discovery` crate - [playback] `alsamixer`: renamed `mixer-card` to `alsa-mixer-device` - [playback] `alsamixer`: renamed `mixer-name` to `alsa-mixer-control` - [playback] `alsamixer`: renamed `mixer-index` to `alsa-mixer-index` ### Removed + - [connect] Removed no-op mixer started/stopped logic (breaking) - [playback] Removed `with-vorbis` and `with-tremor` features - [playback] `alsamixer`: removed `--mixer-linear-volume` option, now that `--volume-ctrl {linear|log}` work as expected on Alsa ### Fixed + - [connect] Fix step size on volume up/down events - [connect] Fix looping back to the first track after the last track of an album or playlist - [playback] Incorrect `PlayerConfig::default().normalisation_threshold` caused distortion when using dynamic volume normalisation downstream From 3145ad91f8fb4879f695c84f2e51803249fd75cf Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Sat, 25 Jan 2025 00:44:36 +0100 Subject: [PATCH 03/21] access token accepts custom message --- oauth/examples/oauth.rs | 2 +- oauth/src/lib.rs | 43 ++++++++++++++++++++++++++++++++--------- src/main.rs | 2 +- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/oauth/examples/oauth.rs b/oauth/examples/oauth.rs index 10e914157..836fb7747 100644 --- a/oauth/examples/oauth.rs +++ b/oauth/examples/oauth.rs @@ -25,7 +25,7 @@ fn main() { return; }; - match get_access_token(client_id, redirect_uri, scopes, false) { + match get_access_token(client_id, redirect_uri, scopes, None) { Ok(token) => println!("Success: {token:#?}"), Err(e) => println!("Failed: {e}"), }; diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index e5fb151c4..6e2b0245e 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -75,6 +75,27 @@ pub struct OAuthToken { pub scopes: Vec, } +#[derive(Default)] +pub enum OpenUrl { + Yes, + #[default] + No, +} + +pub struct OAuthCallbackParams { + pub open_url: OpenUrl, + pub response: String, +} + +impl Default for OAuthCallbackParams { + fn default() -> Self { + Self { + open_url: OpenUrl::default(), + response: String::from("Go back to your terminal :)"), + } + } +} + /// Return code query-string parameter from the redirect URI. fn get_code(redirect_url: &str) -> Result { let url = Url::parse(redirect_url).map_err(|e| OAuthError::AuthCodeBadUri { @@ -105,7 +126,10 @@ fn get_authcode_stdin() -> Result { } /// Spawn HTTP server at provided socket address to accept OAuth callback and return auth code. -fn get_authcode_listener(socket_address: SocketAddr) -> Result { +fn get_authcode_listener( + socket_address: SocketAddr, + message: String, +) -> Result { let listener = TcpListener::bind(socket_address).map_err(|e| OAuthError::AuthCodeListenerBind { addr: socket_address, @@ -131,7 +155,6 @@ fn get_authcode_listener(socket_address: SocketAddr) -> Result, - open_auth_url: bool, + oauth_callback_params: Option, ) -> Result { let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) .map_err(|_| OAuthError::InvalidSpotifyUri)?; @@ -205,14 +228,16 @@ pub fn get_access_token( .set_pkce_challenge(pkce_challenge) .url(); - if open_auth_url { - open::that_in_background(auth_url.as_str()); - } else { - println!("{}", auth_url); + let oauth_callback_params = oauth_callback_params.unwrap_or_default(); + match oauth_callback_params.open_url { + OpenUrl::Yes => { + open::that_in_background(auth_url.as_str()); + } + OpenUrl::No => println!("{}", auth_url), } let code = match get_socket_address(redirect_uri) { - Some(addr) => get_authcode_listener(addr), + Some(addr) => get_authcode_listener(addr, oauth_callback_params.response), _ => get_authcode_stdin(), }?; trace!("Exchange {code:?} for access token"); @@ -252,7 +277,7 @@ pub fn get_access_token( }) } -pub fn get_refresh_token( +pub fn refresh_token( client_id: &str, refresh_token: &str, redirect_uri: &str, diff --git a/src/main.rs b/src/main.rs index c6c2419cc..24039038e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1899,7 +1899,7 @@ async fn main() { &setup.session_config.client_id, &format!("http://127.0.0.1{port_str}/login"), OAUTH_SCOPES.to_vec(), - false, + None, ) { Ok(token) => token.access_token, Err(e) => { From 48d5c08fc12ff3d77123179912769ec685a440fd Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Sat, 25 Jan 2025 01:07:56 +0100 Subject: [PATCH 04/21] docs updated --- CHANGELOG.md | 2 +- oauth/src/lib.rs | 33 ++++++++++++++------------------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 563e272c8..9b8b7c7ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking) - [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking) - [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient` -- [oauth] Add `open_auth_url` parameter to `get_access_token` (breaking) +- [oauth] Add `oauth_callback_params` parameter to `get_access_token` (breaking) - [oauth] Add `refresh_token()` to obtain a new access token from an existing refresh token ### Fixed diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 6e2b0245e..cbfbee241 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -75,23 +75,19 @@ pub struct OAuthToken { pub scopes: Vec, } -#[derive(Default)] -pub enum OpenUrl { - Yes, - #[default] - No, -} - +/// Params to customize `get_access_token()` action. +/// `open_url` set to true opens a new tab in the default browser with the auth url +/// `message` is a custom callback response. Can be an html pub struct OAuthCallbackParams { - pub open_url: OpenUrl, - pub response: String, + pub open_url: bool, + pub message: String, } impl Default for OAuthCallbackParams { fn default() -> Self { Self { - open_url: OpenUrl::default(), - response: String::from("Go back to your terminal :)"), + open_url: false, + message: String::from("Go back to your terminal :)"), } } } @@ -188,8 +184,8 @@ fn get_socket_address(redirect_uri: &str) -> Option { /// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. /// The `redirect_uri` must match what is registered to the client ID. -/// Set `open_auth_url` to true if you want your default browser to open the auth url automatically, -/// instead of printing it to standard output. +/// Optionally, an instance of `OAuthCallbackParams` can be used to automate the auth url opening +/// and to customize the callback response pub fn get_access_token( client_id: &str, redirect_uri: &str, @@ -229,15 +225,14 @@ pub fn get_access_token( .url(); let oauth_callback_params = oauth_callback_params.unwrap_or_default(); - match oauth_callback_params.open_url { - OpenUrl::Yes => { - open::that_in_background(auth_url.as_str()); - } - OpenUrl::No => println!("{}", auth_url), + if oauth_callback_params.open_url { + open::that_in_background(auth_url.as_str()); + } else { + println!("{}", auth_url); } let code = match get_socket_address(redirect_uri) { - Some(addr) => get_authcode_listener(addr, oauth_callback_params.response), + Some(addr) => get_authcode_listener(addr, oauth_callback_params.message), _ => get_authcode_stdin(), }?; trace!("Exchange {code:?} for access token"); From b9c7512522eb3873580f001312f412f3886e8cb5 Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Sat, 25 Jan 2025 19:33:20 +0100 Subject: [PATCH 05/21] CustomParams renamed --- oauth/src/lib.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index cbfbee241..c41f1d618 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -76,14 +76,14 @@ pub struct OAuthToken { } /// Params to customize `get_access_token()` action. -/// `open_url` set to true opens a new tab in the default browser with the auth url -/// `message` is a custom callback response. Can be an html -pub struct OAuthCallbackParams { +/// `open_url` set to true opens a new tab in the default browser with the auth url. +/// `message` is a custom callback response. +pub struct OAuthCustomParams { pub open_url: bool, pub message: String, } -impl Default for OAuthCallbackParams { +impl Default for OAuthCustomParams { fn default() -> Self { Self { open_url: false, @@ -184,13 +184,13 @@ fn get_socket_address(redirect_uri: &str) -> Option { /// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. /// The `redirect_uri` must match what is registered to the client ID. -/// Optionally, an instance of `OAuthCallbackParams` can be used to automate the auth url opening -/// and to customize the callback response +/// Optionally, an instance of `OAuthCustomParams` can be used to automate the auth url opening +/// and to customize the callback response. pub fn get_access_token( client_id: &str, redirect_uri: &str, scopes: Vec<&str>, - oauth_callback_params: Option, + oauth_callback_params: Option, ) -> Result { let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) .map_err(|_| OAuthError::InvalidSpotifyUri)?; From ea8d7f67a110fd3c71a3d57f399ab31b533be2ef Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Sun, 26 Jan 2025 23:01:33 +0100 Subject: [PATCH 06/21] OAuthToken can be cloned --- oauth/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index c41f1d618..e8a7b1328 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -66,7 +66,7 @@ pub enum OAuthError { ExchangeCode { e: String }, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct OAuthToken { pub access_token: String, pub refresh_token: String, From d34560e58cf357743c442d2eacac79d2c8d0f638 Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Fri, 31 Jan 2025 12:48:53 +0100 Subject: [PATCH 07/21] builder pattern on token management --- oauth/src/lib.rs | 260 ++++++++++++++++++++++++++++++----------------- src/main.rs | 23 +++-- 2 files changed, 181 insertions(+), 102 deletions(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index e8a7b1328..3a5d7357b 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -11,7 +11,7 @@ //! is appropriate for headless systems. use log::{error, info, trace}; -use oauth2::reqwest::http_client; +use oauth2::reqwest::{async_http_client, http_client}; use oauth2::RefreshToken; use oauth2::{ basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, @@ -22,7 +22,6 @@ use std::time::{Duration, Instant}; use std::{ io::{BufRead, BufReader, Write}, net::{SocketAddr, TcpListener}, - sync::mpsc, }; use thiserror::Error; use url::Url; @@ -75,23 +74,6 @@ pub struct OAuthToken { pub scopes: Vec, } -/// Params to customize `get_access_token()` action. -/// `open_url` set to true opens a new tab in the default browser with the auth url. -/// `message` is a custom callback response. -pub struct OAuthCustomParams { - pub open_url: bool, - pub message: String, -} - -impl Default for OAuthCustomParams { - fn default() -> Self { - Self { - open_url: false, - message: String::from("Go back to your terminal :)"), - } - } -} - /// Return code query-string parameter from the redirect URI. fn get_code(redirect_url: &str) -> Result { let url = Url::parse(redirect_url).map_err(|e| OAuthError::AuthCodeBadUri { @@ -182,15 +164,172 @@ fn get_socket_address(redirect_uri: &str) -> Option { None } +pub struct OAuthClient { + scopes: Vec, + redirect_uri: String, + should_open_url: bool, + message: String, + client: BasicClient, +} + +impl OAuthClient { + /// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. + /// The `redirect_uri` must match what is registered to the client ID. + pub async fn get_access_token(&self) -> Result { + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + // Generate the full authorization URL. + // Some of these scopes are unavailable for custom client IDs. Which? + let request_scopes: Vec = + self.scopes.iter().map(|s| Scope::new(s.into())).collect(); + let (auth_url, _) = self + .client + .authorize_url(CsrfToken::new_random) + .add_scopes(request_scopes) + .set_pkce_challenge(pkce_challenge) + .url(); + + if self.should_open_url { + open::that_in_background(auth_url.as_str()); + } else { + println!("{}", auth_url); + } + + let code = match get_socket_address(&self.redirect_uri) { + Some(addr) => get_authcode_listener(addr, self.message.clone()), + _ => get_authcode_stdin(), + }?; + trace!("Exchange {code:?} for access token"); + + let resp = self + .client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request_async(async_http_client) + .await; + + let token = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + trace!("Obtained new access token: {token:?}"); + + let token_scopes: Vec = match token.scopes() { + Some(s) => s.iter().map(|s| s.to_string()).collect(), + _ => self.scopes.clone(), + }; + let refresh_token = match token.refresh_token() { + Some(t) => t.secret().to_string(), + _ => "".to_string(), + }; + Ok(OAuthToken { + access_token: token.access_token().secret().to_string(), + refresh_token, + expires_at: Instant::now() + + token + .expires_in() + .unwrap_or_else(|| Duration::from_secs(3600)), + token_type: format!("{:?}", token.token_type()).to_string(), + scopes: token_scopes, + }) + } + + pub async fn refresh_token(&self, refresh_token: &str) -> Result { + let refresh_token = RefreshToken::new(refresh_token.to_string()); + let resp = self + .client + .exchange_refresh_token(&refresh_token) + .request_async(async_http_client) + .await; + let token = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + trace!("Obtained new access token: {token:?}"); + + let token_scopes: Vec = token + .scopes() + .map(|s| s.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + + let refresh_token = token + .refresh_token() + .map_or(String::new(), |t| t.secret().to_string()); + + Ok(OAuthToken { + access_token: token.access_token().secret().to_string(), + refresh_token, + expires_at: Instant::now() + + token + .expires_in() + .unwrap_or_else(|| Duration::from_secs(3600)), + token_type: format!("{:?}", token.token_type()).to_string(), + scopes: token_scopes, + }) + } +} +pub struct OAuthClientBuilder { + client_id: String, + redirect_uri: String, + scopes: Vec, + should_open_url: bool, + message: String, +} + +impl OAuthClientBuilder { + pub fn new(client_id: &str, redirect_uri: &str, scopes: Vec<&str>) -> Self { + Self { + client_id: client_id.to_string(), + redirect_uri: redirect_uri.to_string(), + scopes: scopes.iter().map(|s| s.to_string()).collect(), + should_open_url: false, + message: String::from("Go back to your terminal :)"), + } + } + + pub fn open_in_browser(mut self) -> Self { + self.should_open_url = true; + self + } + + pub fn with_custom_message(mut self, message: &str) -> Self { + self.message = message.to_string(); + self + } + + pub fn build(self) -> Result { + let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let token_url = TokenUrl::new("https://accounts.spotify.com/api/token".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let redirect_url = RedirectUrl::new(self.redirect_uri.clone()).map_err(|e| { + OAuthError::InvalidRedirectUri { + uri: self.redirect_uri.clone(), + e, + } + })?; + + let client = BasicClient::new( + ClientId::new(self.client_id.to_string()), + None, + auth_url, + Some(token_url), + ) + .set_redirect_uri(redirect_url); + + Ok(OAuthClient { + scopes: self.scopes, + should_open_url: self.should_open_url, + message: self.message, + redirect_uri: self.redirect_uri, + client, + }) + } +} /// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. /// The `redirect_uri` must match what is registered to the client ID. -/// Optionally, an instance of `OAuthCustomParams` can be used to automate the auth url opening -/// and to customize the callback response. +#[deprecated( + since = "0.7.0", + note = "please use builder pattern with `OAuthClientBuilder` instead" +)] pub fn get_access_token( client_id: &str, redirect_uri: &str, scopes: Vec<&str>, - oauth_callback_params: Option, ) -> Result { let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) .map_err(|_| OAuthError::InvalidSpotifyUri)?; @@ -224,32 +363,19 @@ pub fn get_access_token( .set_pkce_challenge(pkce_challenge) .url(); - let oauth_callback_params = oauth_callback_params.unwrap_or_default(); - if oauth_callback_params.open_url { - open::that_in_background(auth_url.as_str()); - } else { - println!("{}", auth_url); - } + println!("{}", auth_url); let code = match get_socket_address(redirect_uri) { - Some(addr) => get_authcode_listener(addr, oauth_callback_params.message), + Some(addr) => get_authcode_listener(addr, String::from("ayaya")), _ => get_authcode_stdin(), }?; trace!("Exchange {code:?} for access token"); - // Do this sync in another thread because I am too stupid to make the async version work. - let (tx, rx) = mpsc::channel(); - std::thread::spawn(move || { - let resp = client - .exchange_code(code) - .set_pkce_verifier(pkce_verifier) - .request(http_client); - if let Err(e) = tx.send(resp) { - error!("OAuth channel send error: {e}"); - } - }); - let token_response = rx.recv().map_err(|_| OAuthError::Recv)?; - let token = token_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + let resp = client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request(http_client); + let token = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; trace!("Obtained new access token: {token:?}"); let token_scopes: Vec = match token.scopes() { @@ -272,56 +398,6 @@ pub fn get_access_token( }) } -pub fn refresh_token( - client_id: &str, - refresh_token: &str, - redirect_uri: &str, -) -> Result { - let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) - .map_err(|_| OAuthError::InvalidSpotifyUri)?; - let token_url = TokenUrl::new("https://accounts.spotify.com/api/token".to_string()) - .map_err(|_| OAuthError::InvalidSpotifyUri)?; - let redirect_url = - RedirectUrl::new(redirect_uri.to_string()).map_err(|e| OAuthError::InvalidRedirectUri { - uri: redirect_uri.to_string(), - e, - })?; - let client = BasicClient::new( - ClientId::new(client_id.to_string()), - None, - auth_url, - Some(token_url), - ) - .set_redirect_uri(redirect_url); - - let refresh_token = RefreshToken::new(refresh_token.to_string()); - let resp = client - .exchange_refresh_token(&refresh_token) - .request(http_client); - let token = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; - trace!("Obtained new access token: {token:?}"); - - let token_scopes: Vec = token - .scopes() - .map(|s| s.iter().map(|s| s.to_string()).collect()) - .unwrap_or_default(); - - let refresh_token = token - .refresh_token() - .map_or(String::new(), |t| t.secret().to_string()); - - Ok(OAuthToken { - access_token: token.access_token().secret().to_string(), - refresh_token, - expires_at: Instant::now() - + token - .expires_in() - .unwrap_or_else(|| Duration::from_secs(3600)), - token_type: format!("{:?}", token.token_type()).to_string(), - scopes: token_scopes, - }) -} - #[cfg(test)] mod test { use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; diff --git a/src/main.rs b/src/main.rs index 24039038e..58c99b487 100644 --- a/src/main.rs +++ b/src/main.rs @@ -30,6 +30,7 @@ use librespot::{ player::{coefficient_to_duration, duration_to_coefficient, Player}, }, }; +use librespot_oauth::OAuthClientBuilder; use log::{debug, error, info, trace, warn}; use sha1::{Digest, Sha1}; use sysinfo::{ProcessesToUpdate, System}; @@ -1895,19 +1896,21 @@ async fn main() { Some(port) => format!(":{port}"), _ => String::new(), }; - let access_token = match librespot::oauth::get_access_token( + let client = OAuthClientBuilder::new( &setup.session_config.client_id, &format!("http://127.0.0.1{port_str}/login"), OAUTH_SCOPES.to_vec(), - None, - ) { - Ok(token) => token.access_token, - Err(e) => { - error!("Failed to get Spotify access token: {e}"); - exit(1); - } - }; - last_credentials = Some(Credentials::with_access_token(access_token)); + ) + .build() + .unwrap_or_else(|e| { + error!("Failed to create OAuth client: {e}"); + exit(1); + }); + let oauth_token = client.get_access_token().await.unwrap_or_else(|e| { + error!("Failed to get Spotify access token: {e}"); + exit(1); + }); + last_credentials = Some(Credentials::with_access_token(oauth_token.access_token)); connecting = true; } else if discovery.is_none() { error!( From ca9640ad7df126943f1324be471686f51c8ffa3b Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Fri, 31 Jan 2025 15:43:58 +0100 Subject: [PATCH 08/21] changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b8b7c7ae..20c49b4ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [connect] Replaced `ConnectConfig` with `ConnectStateConfig` (breaking) - [connect] Replaced `playing_track_index` field of `SpircLoadCommand` with `playing_track` (breaking) - [connect] Replaced Mercury usage in `Spirc` with Dealer +- [oauth] `get_access_token()` function marked for deprecation and removal of thread overhead ### Added @@ -22,8 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [connect] Add `pause` parameter to `Spirc::disconnect` method (breaking) - [playback] Add `track` field to `PlayerEvent::RepeatChanged` (breaking) - [core] Add `request_with_options` and `request_with_protobuf_and_options` to `SpClient` -- [oauth] Add `oauth_callback_params` parameter to `get_access_token` (breaking) -- [oauth] Add `refresh_token()` to obtain a new access token from an existing refresh token +- [oauth] Add `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process ### Fixed From 74225a19a6da8baeb97ae5f6cc4262453de4b69d Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Fri, 31 Jan 2025 17:04:38 +0100 Subject: [PATCH 09/21] docs and format issues --- Cargo.lock | 1 + oauth/Cargo.toml | 1 + oauth/examples/oauth.rs | 34 +++++++++++++++++++++++++++++----- oauth/examples/response.html | 6 ++++++ oauth/src/lib.rs | 10 ++++++++++ 5 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 oauth/examples/response.html diff --git a/Cargo.lock b/Cargo.lock index 6fb102dc4..ef3494efc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2145,6 +2145,7 @@ dependencies = [ "oauth2", "open", "thiserror 2.0.11", + "tokio", "url", ] diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml index c27aae08b..5da8de252 100644 --- a/oauth/Cargo.toml +++ b/oauth/Cargo.toml @@ -17,3 +17,4 @@ url = "2.2" [dev-dependencies] env_logger = { version = "0.11.2", default-features = false, features = ["color", "humantime", "auto-color"] } +tokio = { version = "1.43.0", features = ["rt-multi-thread", "macros"] } diff --git a/oauth/examples/oauth.rs b/oauth/examples/oauth.rs index 836fb7747..5d8a32fcf 100644 --- a/oauth/examples/oauth.rs +++ b/oauth/examples/oauth.rs @@ -1,11 +1,12 @@ use std::env; -use librespot_oauth::get_access_token; +use librespot_oauth::OAuthClientBuilder; const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; const SPOTIFY_REDIRECT_URI: &str = "http://127.0.0.1:8898/login"; -fn main() { +#[tokio::main] +async fn main() { let mut builder = env_logger::Builder::new(); builder.parse_filters("librespot=trace"); builder.init(); @@ -25,8 +26,31 @@ fn main() { return; }; - match get_access_token(client_id, redirect_uri, scopes, None) { - Ok(token) => println!("Success: {token:#?}"), - Err(e) => println!("Failed: {e}"), + let client = match OAuthClientBuilder::new(client_id, redirect_uri, scopes) + .open_in_browser() + .with_custom_message(include_str!("response.html")) + .build() + { + Ok(client) => client, + Err(err) => { + eprintln!("Unable to build an OAuth client: {}", err); + return; + } }; + + let refresh_token = match client.get_access_token().await { + Ok(token) => { + println!("OAuth Token: {token:#?}"); + token.refresh_token + } + Err(err) => { + println!("Unable to get OAuth Token: {err}"); + return; + } + }; + + match client.refresh_token(&refresh_token).await { + Ok(token) => println!("New refreshed OAuth Token: {token:#?}"), + Err(err) => println!("Unable to get refreshed OAuth Token: {err}"), + } } diff --git a/oauth/examples/response.html b/oauth/examples/response.html new file mode 100644 index 000000000..b8a124c09 --- /dev/null +++ b/oauth/examples/response.html @@ -0,0 +1,6 @@ + + + +

Return to your app!

+ + diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 3a5d7357b..76941c13e 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -164,6 +164,8 @@ fn get_socket_address(redirect_uri: &str) -> Option { None } +/// Struct that handle obtaining and refreshing of access tokens +/// Should not be instantiate by itself, use OAuthClientBuilder instead. pub struct OAuthClient { scopes: Vec, redirect_uri: String, @@ -231,6 +233,7 @@ impl OAuthClient { }) } + /// Creates a new valid OAuth token by a given refresh_token pub async fn refresh_token(&self, refresh_token: &str) -> Result { let refresh_token = RefreshToken::new(refresh_token.to_string()); let resp = self @@ -262,6 +265,8 @@ impl OAuthClient { }) } } + +/// Builder struct through which structures of type OAuthClient are instantiated. pub struct OAuthClientBuilder { client_id: String, redirect_uri: String, @@ -281,16 +286,21 @@ impl OAuthClientBuilder { } } + /// When this function is added to the building process pipeline, the auth url will be + /// displayed on a default web browser. Otherwise, it will be printed through standard output pub fn open_in_browser(mut self) -> Self { self.should_open_url = true; self } + /// When this function is added to the building process pipeline, the body of the response to + /// the callback request will be `message`. This is useful to load custom HTMLs to that &str. pub fn with_custom_message(mut self, message: &str) -> Self { self.message = message.to_string(); self } + /// End of the building process pipeline. If Ok, a OAuthClient instance will be returned. pub fn build(self) -> Result { let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) .map_err(|_| OAuthError::InvalidSpotifyUri)?; From f45d4a7ffe4004420ed0a53a1d4df6a35d8bb4b3 Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Mon, 3 Feb 2025 13:02:19 +0100 Subject: [PATCH 10/21] split methods and finish documentation --- Cargo.lock | 262 ++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + oauth/Cargo.toml | 3 +- oauth/examples/oauth.rs | 11 +- oauth/examples/response.html | 6 - oauth/src/lib.rs | 204 +++++++++++++++++++-------- 6 files changed, 412 insertions(+), 75 deletions(-) delete mode 100644 oauth/examples/response.html diff --git a/Cargo.lock b/Cargo.lock index ef3494efc..fe93c81e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -840,6 +840,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1507,6 +1522,22 @@ dependencies = [ "webpki-roots 0.26.7", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.5.2", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.10" @@ -1989,6 +2020,7 @@ dependencies = [ "librespot-playback", "librespot-protocol", "log", + "reqwest 0.12.12", "sha1", "sysinfo", "thiserror 2.0.11", @@ -2147,6 +2179,7 @@ dependencies = [ "thiserror 2.0.11", "tokio", "url", + "veil", ] [[package]] @@ -2288,6 +2321,23 @@ dependencies = [ "serde", ] +[[package]] +name = "native-tls" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 2.11.1", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.8.0" @@ -2498,7 +2548,7 @@ dependencies = [ "getrandom", "http 0.2.12", "rand", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "serde_path_to_error", @@ -2565,12 +2615,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6" +dependencies = [ + "bitflags 2.7.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "openssl-probe" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "openssl-sys" +version = "0.9.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-operations" version = "0.5.0" @@ -2976,8 +3064,8 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", - "system-configuration", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", "tokio", "tokio-rustls 0.24.1", "tower-service", @@ -2989,6 +3077,50 @@ dependencies = [ "winreg", ] +[[package]] +name = "reqwest" +version = "0.12.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.4.7", + "http 1.2.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.5.2", + "hyper-rustls 0.27.5", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 2.2.0", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "system-configuration 0.6.1", + "tokio", + "tokio-native-tls", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-registry", +] + [[package]] name = "ring" version = "0.17.8" @@ -3597,6 +3729,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.1" @@ -3629,7 +3770,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.7.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", ] [[package]] @@ -3642,6 +3794,16 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "7.0.3" @@ -3798,6 +3960,16 @@ dependencies = [ "syn 2.0.96", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.24.1" @@ -3904,6 +4076,27 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -4048,6 +4241,33 @@ dependencies = [ "rand", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "veil" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f00796f9c5969da55497f5c8802c2e69eaf21c0166fe28b6006c7c4699f4d0e" +dependencies = [ + "once_cell", + "veil-macros", +] + +[[package]] +name = "veil-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b2d5567b6fbd34e8f0488d56b648e67c0d999535f4af2060d14f9074b43e833" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "vergen" version = "9.0.4" @@ -4319,7 +4539,7 @@ version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" dependencies = [ - "windows-result", + "windows-result 0.1.2", "windows-targets 0.52.6", ] @@ -4331,7 +4551,7 @@ checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" dependencies = [ "windows-implement", "windows-interface", - "windows-result", + "windows-result 0.1.2", "windows-targets 0.52.6", ] @@ -4357,6 +4577,17 @@ dependencies = [ "syn 2.0.96", ] +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result 0.2.0", + "windows-strings", + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -4366,6 +4597,25 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index ae89da48d..137a2f085 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ env_logger = { version = "0.11.2", default-features = false, features = ["color futures-util = { version = "0.3", default-features = false } getopts = "0.2" log = "0.4" +reqwest = "0.12.12" sha1 = "0.10" sysinfo = { version = "0.33.0", default-features = false, features = ["system"] } thiserror = "2.0" diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml index 5da8de252..1a324d5a2 100644 --- a/oauth/Cargo.toml +++ b/oauth/Cargo.toml @@ -11,9 +11,10 @@ edition = "2021" [dependencies] log = "0.4" oauth2 = "4.4" -open = "5.3.2" +open = "5.3" thiserror = "2.0" url = "2.2" +veil = "0.2" [dev-dependencies] env_logger = { version = "0.11.2", default-features = false, features = ["color", "humantime", "auto-color"] } diff --git a/oauth/examples/oauth.rs b/oauth/examples/oauth.rs index 5d8a32fcf..a7c9a3a49 100644 --- a/oauth/examples/oauth.rs +++ b/oauth/examples/oauth.rs @@ -5,6 +5,15 @@ use librespot_oauth::OAuthClientBuilder; const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; const SPOTIFY_REDIRECT_URI: &str = "http://127.0.0.1:8898/login"; +const RESPONSE: &str = r#" + + + +

Return to your app!

+ + +"#; + #[tokio::main] async fn main() { let mut builder = env_logger::Builder::new(); @@ -28,7 +37,7 @@ async fn main() { let client = match OAuthClientBuilder::new(client_id, redirect_uri, scopes) .open_in_browser() - .with_custom_message(include_str!("response.html")) + .with_custom_message(RESPONSE) .build() { Ok(client) => client, diff --git a/oauth/examples/response.html b/oauth/examples/response.html deleted file mode 100644 index b8a124c09..000000000 --- a/oauth/examples/response.html +++ /dev/null @@ -1,6 +0,0 @@ - - - -

Return to your app!

- - diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 76941c13e..d25ce57e7 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -1,3 +1,4 @@ +#![warn(missing_docs)] //! Provides a Spotify access token using the OAuth authorization code flow //! with PKCE. //! @@ -11,12 +12,13 @@ //! is appropriate for headless systems. use log::{error, info, trace}; +use oauth2::basic::BasicTokenType; use oauth2::reqwest::{async_http_client, http_client}; -use oauth2::RefreshToken; use oauth2::{ basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenResponse, TokenUrl, }; +use oauth2::{EmptyExtraTokenFields, PkceCodeVerifier, RefreshToken, StandardTokenResponse}; use std::io; use std::time::{Duration, Instant}; use std::{ @@ -25,52 +27,97 @@ use std::{ }; use thiserror::Error; use url::Url; +use veil::Redact; +/// Enumerates possible errors encountered during the OAuth authentication flow. #[derive(Debug, Error)] pub enum OAuthError { + /// The redirect URI cannot be parsed as a valid URL. #[error("Unable to parse redirect URI {uri} ({e})")] - AuthCodeBadUri { uri: String, e: url::ParseError }, - + AuthCodeBadUri { + /// Auth URI. + uri: String, + /// Inner error code. + e: url::ParseError, + }, + + /// The authorization code parameter is missing in the redirect URI. #[error("Auth code param not found in URI {uri}")] - AuthCodeNotFound { uri: String }, + AuthCodeNotFound { + /// Auth URI. + uri: String, + }, + /// Failed to read input from standard input when manually collecting auth code. #[error("Failed to read redirect URI from stdin")] AuthCodeStdinRead, + /// Could not bind TCP listener to the specified socket address for OAuth callback. #[error("Failed to bind server to {addr} ({e})")] - AuthCodeListenerBind { addr: SocketAddr, e: io::Error }, - + AuthCodeListenerBind { + /// Callback address. + addr: SocketAddr, + /// Inner error code. + e: io::Error, + }, + + /// Listener terminated before receiving an OAuth callback connection. #[error("Listener terminated without accepting a connection")] AuthCodeListenerTerminated, + /// Failed to read incoming HTTP request containing OAuth callback. #[error("Failed to read redirect URI from HTTP request")] AuthCodeListenerRead, + /// Received malformed HTTP request for OAuth callback. #[error("Failed to parse redirect URI from HTTP request")] AuthCodeListenerParse, + /// Could not send HTTP response after handling OAuth callback. #[error("Failed to write HTTP response")] AuthCodeListenerWrite, + /// Invalid Spotify authorization endpoint URL. #[error("Invalid Spotify OAuth URI")] InvalidSpotifyUri, + /// Redirect URI failed validation. #[error("Invalid Redirect URI {uri} ({e})")] - InvalidRedirectUri { uri: String, e: url::ParseError }, - + InvalidRedirectUri { + /// Auth URI. + uri: String, + /// Inner error code + e: url::ParseError, + }, + + /// Channel communication failure. #[error("Failed to receive code")] Recv, + /// Token exchange failure with Spotify's authorization server. #[error("Failed to exchange code for access token ({e})")] - ExchangeCode { e: String }, + ExchangeCode { + /// Inner error description + e: String, + }, } -#[derive(Debug, Clone)] +/// Represents an OAuth token used for accessing Spotify's Web API and sessions. +/// +/// All sensitive fields are redacted when printed or logged for security purposes using +/// [`veil`]'s redaction functionality. +#[derive(Redact, Clone)] +#[redact(all, partial, with = '*')] pub struct OAuthToken { + /// Bearer token used for authenticated Spotify API requests pub access_token: String, + /// Long-lived token used to obtain new access tokens pub refresh_token: String, + /// Instant when the access token becomes invalid pub expires_at: Instant, + /// Type of token pub token_type: String, + /// Permission scopes granted by this token pub scopes: Vec, } @@ -148,6 +195,7 @@ fn get_authcode_listener( // If the specified `redirect_uri` is HTTP, loopback, and contains a port, // then the corresponding socket address is returned. fn get_socket_address(redirect_uri: &str) -> Option { + #![warn(missing_docs)] let url = match Url::parse(redirect_uri) { Ok(u) if u.scheme() == "http" && u.port().is_some() => u, _ => return None, @@ -164,8 +212,9 @@ fn get_socket_address(redirect_uri: &str) -> Option { None } -/// Struct that handle obtaining and refreshing of access tokens -/// Should not be instantiate by itself, use OAuthClientBuilder instead. +/// Struct that handle obtaining and refreshing access tokens. +/// +/// Should not be instantiate by itself, use [`OAuthClientBuilder`] instead. pub struct OAuthClient { scopes: Vec, redirect_uri: String, @@ -175,11 +224,11 @@ pub struct OAuthClient { } impl OAuthClient { - /// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. - /// The `redirect_uri` must match what is registered to the client ID. - pub async fn get_access_token(&self) -> Result { + /// Generates and opens/shows the authentication URL to obtain an access token. + /// + /// Returns a verifier that must be included in the final request for validation. + fn set_auth_url(&self) -> PkceCodeVerifier { let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); - // Generate the full authorization URL. // Some of these scopes are unavailable for custom client IDs. Which? let request_scopes: Vec = @@ -194,75 +243,106 @@ impl OAuthClient { if self.should_open_url { open::that_in_background(auth_url.as_str()); } else { - println!("{}", auth_url); + println!("Browse to: {}", auth_url); } - let code = match get_socket_address(&self.redirect_uri) { - Some(addr) => get_authcode_listener(addr, self.message.clone()), - _ => get_authcode_stdin(), - }?; - trace!("Exchange {code:?} for access token"); - - let resp = self - .client - .exchange_code(code) - .set_pkce_verifier(pkce_verifier) - .request_async(async_http_client) - .await; + pkce_verifier + } - let token = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; - trace!("Obtained new access token: {token:?}"); + fn build_token( + &self, + resp: StandardTokenResponse, + ) -> Result { + trace!("Obtained new access token: {resp:?}"); - let token_scopes: Vec = match token.scopes() { + let token_scopes: Vec = match resp.scopes() { Some(s) => s.iter().map(|s| s.to_string()).collect(), _ => self.scopes.clone(), }; - let refresh_token = match token.refresh_token() { + let refresh_token = match resp.refresh_token() { Some(t) => t.secret().to_string(), - _ => "".to_string(), + _ => "".to_string(), // Spotify always provides a refresh token. }; Ok(OAuthToken { - access_token: token.access_token().secret().to_string(), + access_token: resp.access_token().secret().to_string(), refresh_token, expires_at: Instant::now() - + token + + resp .expires_in() .unwrap_or_else(|| Duration::from_secs(3600)), - token_type: format!("{:?}", token.token_type()).to_string(), + token_type: format!("{:?}", resp.token_type()), scopes: token_scopes, }) } - /// Creates a new valid OAuth token by a given refresh_token + /// Syncronously obtain a Spotify access token using the authorization code with PKCE OAuth flow. + /// + /// `redirect_uri` must match what is registered to the client ID. + pub async fn get_access_token(&self) -> Result { + let pkce_verifier = self.set_auth_url(); + + let code = match get_socket_address(&self.redirect_uri) { + Some(addr) => get_authcode_listener(addr, self.message.clone()), + _ => get_authcode_stdin(), + }?; + trace!("Exchange {code:?} for access token"); + + let resp = self + .client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request(http_client); + + let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + self.build_token(resp) + } + + /// Asynchronously creates a new valid OAuth token by a given refresh_token pub async fn refresh_token(&self, refresh_token: &str) -> Result { let refresh_token = RefreshToken::new(refresh_token.to_string()); let resp = self .client .exchange_refresh_token(&refresh_token) + .request(http_client); + + let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + self.build_token(resp) + } + + /// Asyncronously obtain a Spotify access token using the authorization code with PKCE OAuth flow. + /// + /// `redirect_uri` must match what is registered to the client ID. + pub async fn get_access_token_async(&self) -> Result { + let pkce_verifier = self.set_auth_url(); + + let code = match get_socket_address(&self.redirect_uri) { + Some(addr) => get_authcode_listener(addr, self.message.clone()), + _ => get_authcode_stdin(), + }?; + trace!("Exchange {code:?} for access token"); + + let resp = self + .client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) .request_async(async_http_client) .await; - let token = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; - trace!("Obtained new access token: {token:?}"); - let token_scopes: Vec = token - .scopes() - .map(|s| s.iter().map(|s| s.to_string()).collect()) - .unwrap_or_default(); + let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + self.build_token(resp) + } - let refresh_token = token - .refresh_token() - .map_or(String::new(), |t| t.secret().to_string()); + /// Asynchronously creates a new valid OAuth token by a given refresh_token + pub async fn refresh_token_async(&self, refresh_token: &str) -> Result { + let refresh_token = RefreshToken::new(refresh_token.to_string()); + let resp = self + .client + .exchange_refresh_token(&refresh_token) + .request_async(async_http_client) + .await; - Ok(OAuthToken { - access_token: token.access_token().secret().to_string(), - refresh_token, - expires_at: Instant::now() - + token - .expires_in() - .unwrap_or_else(|| Duration::from_secs(3600)), - token_type: format!("{:?}", token.token_type()).to_string(), - scopes: token_scopes, - }) + let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + self.build_token(resp) } } @@ -276,11 +356,12 @@ pub struct OAuthClientBuilder { } impl OAuthClientBuilder { + /// Create a new OAuthClientBuilder with provided params and default config. pub fn new(client_id: &str, redirect_uri: &str, scopes: Vec<&str>) -> Self { Self { client_id: client_id.to_string(), redirect_uri: redirect_uri.to_string(), - scopes: scopes.iter().map(|s| s.to_string()).collect(), + scopes: scopes.into_iter().map(Into::into).collect(), should_open_url: false, message: String::from("Go back to your terminal :)"), } @@ -330,6 +411,7 @@ impl OAuthClientBuilder { }) } } + /// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. /// The `redirect_uri` must match what is registered to the client ID. #[deprecated( @@ -373,10 +455,10 @@ pub fn get_access_token( .set_pkce_challenge(pkce_challenge) .url(); - println!("{}", auth_url); + println!("Browse to: {}", auth_url); let code = match get_socket_address(redirect_uri) { - Some(addr) => get_authcode_listener(addr, String::from("ayaya")), + Some(addr) => get_authcode_listener(addr, String::from("Go back to your terminal :)")), _ => get_authcode_stdin(), }?; trace!("Exchange {code:?} for access token"); @@ -403,7 +485,7 @@ pub fn get_access_token( + token .expires_in() .unwrap_or_else(|| Duration::from_secs(3600)), - token_type: format!("{:?}", token.token_type()).to_string(), // Urgh!? + token_type: format!("{:?}", token.token_type()), scopes: token_scopes, }) } From 7bf36738593a33d5fbe24c32df5c56daeb71ae99 Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Mon, 3 Feb 2025 13:53:55 +0100 Subject: [PATCH 11/21] new example and minor adjustments --- oauth/examples/{oauth.rs => oauth_async.rs} | 4 +- oauth/examples/oauth_sync.rs | 64 +++++++++++++++++++++ oauth/src/lib.rs | 4 +- src/main.rs | 2 +- 4 files changed, 69 insertions(+), 5 deletions(-) rename oauth/examples/{oauth.rs => oauth_async.rs} (92%) create mode 100644 oauth/examples/oauth_sync.rs diff --git a/oauth/examples/oauth.rs b/oauth/examples/oauth_async.rs similarity index 92% rename from oauth/examples/oauth.rs rename to oauth/examples/oauth_async.rs index a7c9a3a49..a8b26799a 100644 --- a/oauth/examples/oauth.rs +++ b/oauth/examples/oauth_async.rs @@ -47,7 +47,7 @@ async fn main() { } }; - let refresh_token = match client.get_access_token().await { + let refresh_token = match client.get_access_token_async().await { Ok(token) => { println!("OAuth Token: {token:#?}"); token.refresh_token @@ -58,7 +58,7 @@ async fn main() { } }; - match client.refresh_token(&refresh_token).await { + match client.refresh_token_async(&refresh_token).await { Ok(token) => println!("New refreshed OAuth Token: {token:#?}"), Err(err) => println!("Unable to get refreshed OAuth Token: {err}"), } diff --git a/oauth/examples/oauth_sync.rs b/oauth/examples/oauth_sync.rs new file mode 100644 index 000000000..af773d20b --- /dev/null +++ b/oauth/examples/oauth_sync.rs @@ -0,0 +1,64 @@ +use std::env; + +use librespot_oauth::OAuthClientBuilder; + +const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; +const SPOTIFY_REDIRECT_URI: &str = "http://127.0.0.1:8898/login"; + +const RESPONSE: &str = r#" + + + +

Return to your app!

+ + +"#; + +fn main() { + let mut builder = env_logger::Builder::new(); + builder.parse_filters("librespot=trace"); + builder.init(); + + let args: Vec<_> = env::args().collect(); + let (client_id, redirect_uri, scopes) = if args.len() == 4 { + // You can use your own client ID, along with it's associated redirect URI. + ( + args[1].as_str(), + args[2].as_str(), + args[3].split(',').collect::>(), + ) + } else if args.len() == 1 { + (SPOTIFY_CLIENT_ID, SPOTIFY_REDIRECT_URI, vec!["streaming"]) + } else { + eprintln!("Usage: {} [CLIENT_ID REDIRECT_URI SCOPES]", args[0]); + return; + }; + + let client = match OAuthClientBuilder::new(client_id, redirect_uri, scopes) + .open_in_browser() + .with_custom_message(RESPONSE) + .build() + { + Ok(client) => client, + Err(err) => { + eprintln!("Unable to build an OAuth client: {}", err); + return; + } + }; + + let refresh_token = match client.get_access_token() { + Ok(token) => { + println!("OAuth Token: {token:#?}"); + token.refresh_token + } + Err(err) => { + println!("Unable to get OAuth Token: {err}"); + return; + } + }; + + match client.refresh_token(&refresh_token) { + Ok(token) => println!("New refreshed OAuth Token: {token:#?}"), + Err(err) => println!("Unable to get refreshed OAuth Token: {err}"), + } +} diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index d25ce57e7..b75e129b3 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -278,7 +278,7 @@ impl OAuthClient { /// Syncronously obtain a Spotify access token using the authorization code with PKCE OAuth flow. /// /// `redirect_uri` must match what is registered to the client ID. - pub async fn get_access_token(&self) -> Result { + pub fn get_access_token(&self) -> Result { let pkce_verifier = self.set_auth_url(); let code = match get_socket_address(&self.redirect_uri) { @@ -298,7 +298,7 @@ impl OAuthClient { } /// Asynchronously creates a new valid OAuth token by a given refresh_token - pub async fn refresh_token(&self, refresh_token: &str) -> Result { + pub fn refresh_token(&self, refresh_token: &str) -> Result { let refresh_token = RefreshToken::new(refresh_token.to_string()); let resp = self .client diff --git a/src/main.rs b/src/main.rs index 58c99b487..97d98141d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1906,7 +1906,7 @@ async fn main() { error!("Failed to create OAuth client: {e}"); exit(1); }); - let oauth_token = client.get_access_token().await.unwrap_or_else(|e| { + let oauth_token = client.get_access_token().unwrap_or_else(|e| { error!("Failed to get Spotify access token: {e}"); exit(1); }); From 22958791a5e2d376cb658bf04c36ebb8f5319a26 Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Mon, 3 Feb 2025 13:59:06 +0100 Subject: [PATCH 12/21] typo --- CHANGELOG.md | 5 ++++- oauth/src/lib.rs | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bdbab9af..28a770722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [connect] Replaced `ConnectConfig` with `ConnectStateConfig` (breaking) - [connect] Replaced `playing_track_index` field of `SpircLoadCommand` with `playing_track` (breaking) - [connect] Replaced Mercury usage in `Spirc` with Dealer -- [oauth] `get_access_token()` function marked for deprecation and removal of thread overhead ### Added @@ -41,6 +40,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [connect] Handle transfer of playback with empty "uri" field - [connect] Correctly apply playing/paused state when transferring playback +### Deprecated + +- [oauth] `get_access_token()` function marked for deprecation and removal of thread overhead + ### Removed - [core] Removed `get_canvases` from SpClient (breaking) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index b75e129b3..fe6717969 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -297,7 +297,7 @@ impl OAuthClient { self.build_token(resp) } - /// Asynchronously creates a new valid OAuth token by a given refresh_token + /// Synchronously creates a new valid OAuth token by a given refresh_token pub fn refresh_token(&self, refresh_token: &str) -> Result { let refresh_token = RefreshToken::new(refresh_token.to_string()); let resp = self From 8541da615e66827686dea357bb95c7643798442e Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Mon, 3 Feb 2025 15:26:09 +0100 Subject: [PATCH 13/21] remove unnecessary dependency --- Cargo.lock | 240 ++--------------------------------------------------- Cargo.toml | 1 - 2 files changed, 6 insertions(+), 235 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fe93c81e7..dcd674e9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -840,21 +840,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.1" @@ -1522,22 +1507,6 @@ dependencies = [ "webpki-roots 0.26.7", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.5.2", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.10" @@ -2020,7 +1989,6 @@ dependencies = [ "librespot-playback", "librespot-protocol", "log", - "reqwest 0.12.12", "sha1", "sysinfo", "thiserror 2.0.11", @@ -2321,23 +2289,6 @@ dependencies = [ "serde", ] -[[package]] -name = "native-tls" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dab59f8e050d5df8e4dd87d9206fb6f65a483e20ac9fda365ade4fab353196c" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework 2.11.1", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndk" version = "0.8.0" @@ -2548,7 +2499,7 @@ dependencies = [ "getrandom", "http 0.2.12", "rand", - "reqwest 0.11.27", + "reqwest", "serde", "serde_json", "serde_path_to_error", @@ -2615,50 +2566,12 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "openssl" -version = "0.10.70" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61cfb4e166a8bb8c9b55c500bc2308550148ece889be90f609377e58140f42c6" -dependencies = [ - "bitflags 2.7.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.96", -] - [[package]] name = "openssl-probe" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" -[[package]] -name = "openssl-sys" -version = "0.9.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b22d5b84be05a8d6947c7cb71f7c849aa0f112acd4bf51c2a7c1c988ac0a9dc" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-operations" version = "0.5.0" @@ -3064,8 +2977,8 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration 0.5.1", + "sync_wrapper", + "system-configuration", "tokio", "tokio-rustls 0.24.1", "tower-service", @@ -3077,50 +2990,6 @@ dependencies = [ "winreg", ] -[[package]] -name = "reqwest" -version = "0.12.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" -dependencies = [ - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2 0.4.7", - "http 1.2.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.5.2", - "hyper-rustls 0.27.5", - "hyper-tls", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile 2.2.0", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 1.0.2", - "system-configuration 0.6.1", - "tokio", - "tokio-native-tls", - "tower", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-registry", -] - [[package]] name = "ring" version = "0.17.8" @@ -3729,15 +3598,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - [[package]] name = "synstructure" version = "0.13.1" @@ -3770,18 +3630,7 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "system-configuration-sys 0.5.0", -] - -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.7.0", - "core-foundation 0.9.4", - "system-configuration-sys 0.6.0", + "system-configuration-sys", ] [[package]] @@ -3794,16 +3643,6 @@ dependencies = [ "libc", ] -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "system-deps" version = "7.0.3" @@ -3960,16 +3799,6 @@ dependencies = [ "syn 2.0.96", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.24.1" @@ -4076,27 +3905,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper 1.0.2", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - [[package]] name = "tower-service" version = "0.3.3" @@ -4241,12 +4049,6 @@ dependencies = [ "rand", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "veil" version = "0.2.0" @@ -4539,7 +4341,7 @@ version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" dependencies = [ - "windows-result 0.1.2", + "windows-result", "windows-targets 0.52.6", ] @@ -4551,7 +4353,7 @@ checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" dependencies = [ "windows-implement", "windows-interface", - "windows-result 0.1.2", + "windows-result", "windows-targets 0.52.6", ] @@ -4577,17 +4379,6 @@ dependencies = [ "syn 2.0.96", ] -[[package]] -name = "windows-registry" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" -dependencies = [ - "windows-result 0.2.0", - "windows-strings", - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.1.2" @@ -4597,25 +4388,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 137a2f085..ae89da48d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,6 @@ env_logger = { version = "0.11.2", default-features = false, features = ["color futures-util = { version = "0.3", default-features = false } getopts = "0.2" log = "0.4" -reqwest = "0.12.12" sha1 = "0.10" sysinfo = { version = "0.33.0", default-features = false, features = ["system"] } thiserror = "2.0" From 868ff661a528577e8da37d7a3e08eb373acb4a8d Mon Sep 17 00:00:00 2001 From: Charlie Bacon Date: Mon, 3 Feb 2025 20:03:30 +0100 Subject: [PATCH 14/21] requested changes --- oauth/src/lib.rs | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index fe6717969..899d1547a 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -20,6 +20,7 @@ use oauth2::{ }; use oauth2::{EmptyExtraTokenFields, PkceCodeVerifier, RefreshToken, StandardTokenResponse}; use std::io; +use std::sync::mpsc; use std::time::{Duration, Instant}; use std::{ io::{BufRead, BufReader, Write}, @@ -224,7 +225,7 @@ pub struct OAuthClient { } impl OAuthClient { - /// Generates and opens/shows the authentication URL to obtain an access token. + /// Generates and opens/shows the authorization URL to obtain an access token. /// /// Returns a verifier that must be included in the final request for validation. fn set_auth_url(&self) -> PkceCodeVerifier { @@ -276,8 +277,6 @@ impl OAuthClient { } /// Syncronously obtain a Spotify access token using the authorization code with PKCE OAuth flow. - /// - /// `redirect_uri` must match what is registered to the client ID. pub fn get_access_token(&self) -> Result { let pkce_verifier = self.set_auth_url(); @@ -297,7 +296,7 @@ impl OAuthClient { self.build_token(resp) } - /// Synchronously creates a new valid OAuth token by a given refresh_token + /// Synchronously obtain a new valid OAuth token from `refresh_token` pub fn refresh_token(&self, refresh_token: &str) -> Result { let refresh_token = RefreshToken::new(refresh_token.to_string()); let resp = self @@ -310,8 +309,6 @@ impl OAuthClient { } /// Asyncronously obtain a Spotify access token using the authorization code with PKCE OAuth flow. - /// - /// `redirect_uri` must match what is registered to the client ID. pub async fn get_access_token_async(&self) -> Result { let pkce_verifier = self.set_auth_url(); @@ -332,7 +329,7 @@ impl OAuthClient { self.build_token(resp) } - /// Asynchronously creates a new valid OAuth token by a given refresh_token + /// Asynchronously obtain a new valid OAuth token from `refresh_token` pub async fn refresh_token_async(&self, refresh_token: &str) -> Result { let refresh_token = RefreshToken::new(refresh_token.to_string()); let resp = self @@ -349,7 +346,7 @@ impl OAuthClient { /// Builder struct through which structures of type OAuthClient are instantiated. pub struct OAuthClientBuilder { client_id: String, - redirect_uri: String, + redirect_uri: String, // must match what is registered to the client ID scopes: Vec, should_open_url: bool, message: String, @@ -418,6 +415,8 @@ impl OAuthClientBuilder { since = "0.7.0", note = "please use builder pattern with `OAuthClientBuilder` instead" )] +/// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. +/// The redirect_uri must match what is registered to the client ID. pub fn get_access_token( client_id: &str, redirect_uri: &str, @@ -463,11 +462,19 @@ pub fn get_access_token( }?; trace!("Exchange {code:?} for access token"); - let resp = client - .exchange_code(code) - .set_pkce_verifier(pkce_verifier) - .request(http_client); - let token = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + // Do this sync in another thread because I am too stupid to make the async version work. + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let resp = client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request(http_client); + if let Err(e) = tx.send(resp) { + error!("OAuth channel send error: {e}"); + } + }); + let token_response = rx.recv().map_err(|_| OAuthError::Recv)?; + let token = token_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; trace!("Obtained new access token: {token:?}"); let token_scopes: Vec = match token.scopes() { @@ -485,7 +492,7 @@ pub fn get_access_token( + token .expires_in() .unwrap_or_else(|| Duration::from_secs(3600)), - token_type: format!("{:?}", token.token_type()), + token_type: format!("{:?}", token.token_type()).to_string(), // Urgh!? scopes: token_scopes, }) } From 345a34e55960481a192586be1205b6db1b939596 Mon Sep 17 00:00:00 2001 From: Carlos Tocino Date: Tue, 4 Feb 2025 21:03:23 +0100 Subject: [PATCH 15/21] Update oauth/src/lib.rs Co-authored-by: Felix Prillwitz --- oauth/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 899d1547a..9ce3115d2 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -354,6 +354,8 @@ pub struct OAuthClientBuilder { impl OAuthClientBuilder { /// Create a new OAuthClientBuilder with provided params and default config. + /// + /// `redirect_uri` must match to the registered Uris of `client_id` pub fn new(client_id: &str, redirect_uri: &str, scopes: Vec<&str>) -> Self { Self { client_id: client_id.to_string(), From 792cc158f183a104616258dc240fb2b97616d92b Mon Sep 17 00:00:00 2001 From: Carlos Tocino Date: Tue, 4 Feb 2025 21:03:34 +0100 Subject: [PATCH 16/21] Update oauth/src/lib.rs Co-authored-by: Felix Prillwitz --- oauth/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 9ce3115d2..2cde20895 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -346,7 +346,7 @@ impl OAuthClient { /// Builder struct through which structures of type OAuthClient are instantiated. pub struct OAuthClientBuilder { client_id: String, - redirect_uri: String, // must match what is registered to the client ID + redirect_uri: String, scopes: Vec, should_open_url: bool, message: String, From 64d2048d587e240ca1591ddb647fdb71530e0a18 Mon Sep 17 00:00:00 2001 From: Carlos Tocino Date: Tue, 4 Feb 2025 21:03:42 +0100 Subject: [PATCH 17/21] Update CHANGELOG.md Co-authored-by: Felix Prillwitz --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a770722..878999be6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,7 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Deprecated -- [oauth] `get_access_token()` function marked for deprecation and removal of thread overhead +- [oauth] `get_access_token()` function marked for deprecation ### Removed From c2322e4b4efae15bdd331e158939ad425b6a495c Mon Sep 17 00:00:00 2001 From: Carlos Tocino Date: Tue, 4 Feb 2025 21:03:54 +0100 Subject: [PATCH 18/21] Update oauth/src/lib.rs Co-authored-by: Felix Prillwitz --- oauth/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 2cde20895..d2d6448ec 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -30,7 +30,7 @@ use thiserror::Error; use url::Url; use veil::Redact; -/// Enumerates possible errors encountered during the OAuth authentication flow. +/// Possible errors encountered during the OAuth authentication flow. #[derive(Debug, Error)] pub enum OAuthError { /// The redirect URI cannot be parsed as a valid URL. From ddf47da4afa8231678aa275afaf3740a184f3844 Mon Sep 17 00:00:00 2001 From: Carlos Tocino Date: Wed, 5 Feb 2025 10:26:30 +0100 Subject: [PATCH 19/21] Update oauth/src/lib.rs Co-authored-by: Felix Prillwitz --- oauth/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index d2d6448ec..58c599aa7 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -214,8 +214,6 @@ fn get_socket_address(redirect_uri: &str) -> Option { } /// Struct that handle obtaining and refreshing access tokens. -/// -/// Should not be instantiate by itself, use [`OAuthClientBuilder`] instead. pub struct OAuthClient { scopes: Vec, redirect_uri: String, From 942a5b12df8f7a19d92c080a38396031101b35ad Mon Sep 17 00:00:00 2001 From: Carlos Tocino Date: Thu, 6 Feb 2025 20:33:32 +0100 Subject: [PATCH 20/21] Update oauth/src/lib.rs Co-authored-by: Nick Steel --- oauth/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 58c599aa7..91fda6f82 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -365,7 +365,7 @@ impl OAuthClientBuilder { } /// When this function is added to the building process pipeline, the auth url will be - /// displayed on a default web browser. Otherwise, it will be printed through standard output + /// opened with the default web browser. Otherwise, it will be printed to standard output. pub fn open_in_browser(mut self) -> Self { self.should_open_url = true; self From 738ccf39e9371fc6878d7d4e2dc5ac804723cbfe Mon Sep 17 00:00:00 2001 From: Carlos Tocino Date: Thu, 6 Feb 2025 20:33:38 +0100 Subject: [PATCH 21/21] Update oauth/src/lib.rs Co-authored-by: Nick Steel --- oauth/src/lib.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 91fda6f82..2b84290ec 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -241,9 +241,8 @@ impl OAuthClient { if self.should_open_url { open::that_in_background(auth_url.as_str()); - } else { - println!("Browse to: {}", auth_url); } + println!("Browse to: {}", auth_url); pkce_verifier }