From f497806fb1cc85c81ee9feb95f225221bf7a6b53 Mon Sep 17 00:00:00 2001 From: Carlos Tocino Date: Tue, 18 Feb 2025 16:39:31 +0100 Subject: [PATCH] OAuth process made by a struct, allowing customization options. (#1462) * get refresh token. Optional auth url browser opening * changelog * access token accepts custom message * docs updated * CustomParams renamed * OAuthToken can be cloned * builder pattern on token management * changelog * docs and format issues * split methods and finish documentation * new example and minor adjustments * typo * remove unnecessary dependency * requested changes * Update oauth/src/lib.rs Co-authored-by: Felix Prillwitz * Update oauth/src/lib.rs Co-authored-by: Felix Prillwitz * Update CHANGELOG.md Co-authored-by: Felix Prillwitz * Update oauth/src/lib.rs Co-authored-by: Felix Prillwitz * Update oauth/src/lib.rs Co-authored-by: Felix Prillwitz * Update oauth/src/lib.rs Co-authored-by: Nick Steel * Update oauth/src/lib.rs Co-authored-by: Nick Steel * remove veil. Oauth flow fix * debug trait instead of veil * Update main.rs Co-authored-by: Nick Steel --------- Co-authored-by: Felix Prillwitz Co-authored-by: Nick Steel --- CHANGELOG.md | 23 ++- Cargo.lock | 38 +++++ oauth/Cargo.toml | 2 + oauth/examples/oauth.rs | 32 ---- oauth/examples/oauth_async.rs | 65 ++++++++ oauth/examples/oauth_sync.rs | 64 ++++++++ oauth/src/lib.rs | 277 ++++++++++++++++++++++++++++++++-- src/main.rs | 23 +-- 8 files changed, 471 insertions(+), 53 deletions(-) delete mode 100644 oauth/examples/oauth.rs create mode 100644 oauth/examples/oauth_async.rs create mode 100644 oauth/examples/oauth_sync.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e8db0b46..878999be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +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 `OAuthClient` and `OAuthClientBuilder` structs to achieve a more customizable login process ### Fixed @@ -39,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 + ### Removed - [core] Removed `get_canvases` from SpClient (breaking) @@ -76,7 +81,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 @@ -219,14 +224,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` @@ -235,9 +243,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 @@ -253,6 +263,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 @@ -265,6 +276,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 @@ -277,6 +289,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 @@ -287,20 +300,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 @@ -309,6 +326,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 @@ -325,17 +343,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 diff --git a/Cargo.lock b/Cargo.lock index 4bf54243..4f861b91 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,7 +2143,9 @@ dependencies = [ "env_logger", "log", "oauth2", + "open", "thiserror 2.0.11", + "tokio", "url", ] @@ -2534,6 +2555,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" @@ -2597,6 +2629,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 c3d4a81b..d95a5431 100644 --- a/oauth/Cargo.toml +++ b/oauth/Cargo.toml @@ -11,8 +11,10 @@ edition = "2021" [dependencies] log = "0.4" oauth2 = "4.4" +open = "5.3" thiserror = "2.0" 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 deleted file mode 100644 index 76ff088e..00000000 --- a/oauth/examples/oauth.rs +++ /dev/null @@ -1,32 +0,0 @@ -use std::env; - -use librespot_oauth::get_access_token; - -const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; -const SPOTIFY_REDIRECT_URI: &str = "http://127.0.0.1:8898/login"; - -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; - }; - - match get_access_token(client_id, redirect_uri, scopes) { - Ok(token) => println!("Success: {token:#?}"), - Err(e) => println!("Failed: {e}"), - }; -} diff --git a/oauth/examples/oauth_async.rs b/oauth/examples/oauth_async.rs new file mode 100644 index 00000000..a8b26799 --- /dev/null +++ b/oauth/examples/oauth_async.rs @@ -0,0 +1,65 @@ +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!

+ + +"#; + +#[tokio::main] +async 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_async().await { + Ok(token) => { + println!("OAuth Token: {token:#?}"); + token.refresh_token + } + Err(err) => { + println!("Unable to get OAuth Token: {err}"); + return; + } + }; + + 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 00000000..af773d20 --- /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 591e6559..284f08d7 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,66 +12,108 @@ //! is appropriate for headless systems. use log::{error, info, trace}; -use oauth2::reqwest::http_client; +use oauth2::basic::BasicTokenType; +use oauth2::reqwest::{async_http_client, http_client}; use oauth2::{ basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenResponse, TokenUrl, }; +use oauth2::{EmptyExtraTokenFields, PkceCodeVerifier, RefreshToken, StandardTokenResponse}; use std::io; +use std::sync::mpsc; use std::time::{Duration, Instant}; use std::{ io::{BufRead, BufReader, Write}, net::{SocketAddr, TcpListener}, - sync::mpsc, }; use thiserror::Error; use url::Url; +/// 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)] +/// Represents an OAuth token used for accessing Spotify's Web API and sessions. +#[derive(Debug, Clone)] 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, } @@ -104,7 +147,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, @@ -130,7 +176,6 @@ fn get_authcode_listener(socket_address: SocketAddr) -> Result Result Option { + #![warn(missing_docs)] let url = match Url::parse(redirect_uri) { Ok(u) if u.scheme() == "http" && u.port().is_some() => u, _ => return None, @@ -162,6 +208,215 @@ fn get_socket_address(redirect_uri: &str) -> Option { None } +/// Struct that handle obtaining and refreshing access tokens. +pub struct OAuthClient { + scopes: Vec, + redirect_uri: String, + should_open_url: bool, + message: String, + client: BasicClient, +} + +impl OAuthClient { + /// 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 { + 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()); + } + println!("Browse to: {}", auth_url); + + pkce_verifier + } + + fn build_token( + &self, + resp: StandardTokenResponse, + ) -> Result { + trace!("Obtained new access token: {resp:?}"); + + let token_scopes: Vec = match resp.scopes() { + Some(s) => s.iter().map(|s| s.to_string()).collect(), + _ => self.scopes.clone(), + }; + let refresh_token = match resp.refresh_token() { + Some(t) => t.secret().to_string(), + _ => "".to_string(), // Spotify always provides a refresh token. + }; + Ok(OAuthToken { + access_token: resp.access_token().secret().to_string(), + refresh_token, + expires_at: Instant::now() + + resp + .expires_in() + .unwrap_or_else(|| Duration::from_secs(3600)), + token_type: format!("{:?}", resp.token_type()), + scopes: token_scopes, + }) + } + + /// Syncronously obtain a Spotify access token using the authorization code with PKCE OAuth flow. + pub 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 (tx, rx) = mpsc::channel(); + let client = self.client.clone(); + 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 channel_response = rx.recv().map_err(|_| OAuthError::Recv)?; + let token_response = + channel_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + + self.build_token(token_response) + } + + /// 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 + .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. + 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 resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + self.build_token(resp) + } + + /// 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 + .client + .exchange_refresh_token(&refresh_token) + .request_async(async_http_client) + .await; + + let resp = resp.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + self.build_token(resp) + } +} + +/// Builder struct through which structures of type OAuthClient are instantiated. +pub struct OAuthClientBuilder { + client_id: String, + redirect_uri: String, + scopes: Vec, + should_open_url: bool, + message: String, +} + +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(), + redirect_uri: redirect_uri.to_string(), + scopes: scopes.into_iter().map(Into::into).collect(), + should_open_url: false, + message: String::from("Go back to your terminal :)"), + } + } + + /// When this function is added to the building process pipeline, the auth url will be + /// 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 + } + + /// 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)?; + 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. +#[deprecated( + 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( @@ -204,7 +459,7 @@ pub fn get_access_token( println!("Browse to: {}", auth_url); let code = match get_socket_address(redirect_uri) { - Some(addr) => get_authcode_listener(addr), + Some(addr) => get_authcode_listener(addr, String::from("Go back to your terminal :)")), _ => get_authcode_stdin(), }?; trace!("Exchange {code:?} for access token"); diff --git a/src/main.rs b/src/main.rs index 2d6c9614..f1dd0271 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,18 +1896,22 @@ 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(), - ) { - 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)); + ) + .open_in_browser() + .build() + .unwrap_or_else(|e| { + error!("Failed to create OAuth client: {e}"); + exit(1); + }); + let oauth_token = client.get_access_token().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!(