1
0
Fork 0
mirror of https://github.com/librespot-org/librespot.git synced 2025-10-03 09:49:31 +02:00

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 <photovoltex@mailbox.org>

* Update oauth/src/lib.rs

Co-authored-by: Felix Prillwitz <photovoltex@mailbox.org>

* Update CHANGELOG.md

Co-authored-by: Felix Prillwitz <photovoltex@mailbox.org>

* Update oauth/src/lib.rs

Co-authored-by: Felix Prillwitz <photovoltex@mailbox.org>

* Update oauth/src/lib.rs

Co-authored-by: Felix Prillwitz <photovoltex@mailbox.org>

* Update oauth/src/lib.rs

Co-authored-by: Nick Steel <nick@nsteel.co.uk>

* Update oauth/src/lib.rs

Co-authored-by: Nick Steel <nick@nsteel.co.uk>

* remove veil. Oauth flow fix

* debug trait instead of veil

* Update main.rs

Co-authored-by: Nick Steel <nick@nsteel.co.uk>

---------

Co-authored-by: Felix Prillwitz <photovoltex@mailbox.org>
Co-authored-by: Nick Steel <nick@nsteel.co.uk>
This commit is contained in:
Carlos Tocino 2025-02-18 16:39:31 +01:00 committed by GitHub
parent 581c8d61ea
commit f497806fb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 471 additions and 53 deletions

View file

@ -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

38
Cargo.lock generated
View file

@ -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"

View file

@ -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"] }

View file

@ -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::<Vec<&str>>(),
)
} 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}"),
};
}

View file

@ -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#"
<!doctype html>
<html>
<body>
<h1>Return to your app!</h1>
</body>
</html>
"#;
#[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::<Vec<&str>>(),
)
} 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}"),
}
}

View file

@ -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#"
<!doctype html>
<html>
<body>
<h1>Return to your app!</h1>
</body>
</html>
"#;
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::<Vec<&str>>(),
)
} 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}"),
}
}

View file

@ -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<String>,
}
@ -104,7 +147,10 @@ fn get_authcode_stdin() -> Result<AuthorizationCode, OAuthError> {
}
/// Spawn HTTP server at provided socket address to accept OAuth callback and return auth code.
fn get_authcode_listener(socket_address: SocketAddr) -> Result<AuthorizationCode, OAuthError> {
fn get_authcode_listener(
socket_address: SocketAddr,
message: String,
) -> Result<AuthorizationCode, OAuthError> {
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<AuthorizationCode
.ok_or(OAuthError::AuthCodeListenerParse)?;
let code = get_code(&("http://localhost".to_string() + redirect_url));
let message = "Go back to your terminal :)";
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}",
message.len(),
@ -146,6 +191,7 @@ fn get_authcode_listener(socket_address: SocketAddr) -> Result<AuthorizationCode
// 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<SocketAddr> {
#![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<SocketAddr> {
None
}
/// Struct that handle obtaining and refreshing access tokens.
pub struct OAuthClient {
scopes: Vec<String>,
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<oauth2::Scope> =
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<EmptyExtraTokenFields, BasicTokenType>,
) -> Result<OAuthToken, OAuthError> {
trace!("Obtained new access token: {resp:?}");
let token_scopes: Vec<String> = 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<OAuthToken, OAuthError> {
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<OAuthToken, OAuthError> {
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<OAuthToken, OAuthError> {
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<OAuthToken, OAuthError> {
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<String>,
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<OAuthClient, OAuthError> {
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");

View file

@ -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!(