From 7a45614ac477e8f409b68363f825726fcdda23c8 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Fri, 13 Jan 2023 15:59:02 +0000 Subject: [PATCH 01/21] core: Create credentials from access token --- core/src/authentication.rs | 16 ++++++++++++---- core/src/connection/mod.rs | 1 + examples/get_token.rs | 36 ++++++++++++++++++++++++++---------- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/core/src/authentication.rs b/core/src/authentication.rs index 8122d6591..b2448ab2a 100644 --- a/core/src/authentication.rs +++ b/core/src/authentication.rs @@ -50,19 +50,27 @@ impl Credentials { /// /// let creds = Credentials::with_password("my account", "my password"); /// ``` - pub fn with_password(username: impl Into, password: impl Into) -> Credentials { - Credentials { + pub fn with_password(username: impl Into, password: impl Into) -> Self { + Self { username: username.into(), auth_type: AuthenticationType::AUTHENTICATION_USER_PASS, auth_data: password.into().into_bytes(), } } + pub fn with_access_token(username: impl Into, token: impl Into) -> Self { + Self { + username: username.into(), + auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN, + auth_data: token.into().into_bytes(), + } + } + pub fn with_blob( username: impl Into, encrypted_blob: impl AsRef<[u8]>, device_id: impl AsRef<[u8]>, - ) -> Result { + ) -> Result { fn read_u8(stream: &mut R) -> io::Result { let mut data = [0u8]; stream.read_exact(&mut data)?; @@ -136,7 +144,7 @@ impl Credentials { read_u8(&mut cursor)?; let auth_data = read_bytes(&mut cursor)?; - Ok(Credentials { + Ok(Self { username, auth_type, auth_data, diff --git a/core/src/connection/mod.rs b/core/src/connection/mod.rs index 4bac6e3e3..03c5fec1f 100644 --- a/core/src/connection/mod.rs +++ b/core/src/connection/mod.rs @@ -133,6 +133,7 @@ pub async fn authenticate( let cmd = PacketType::Login; let data = packet.write_to_bytes()?; + debug!("Authenticating with AP using {:?}", credentials.auth_type); transport.send((cmd as u8, data)).await?; let (cmd, data) = transport .next() diff --git a/examples/get_token.rs b/examples/get_token.rs index 0473e1226..8a9cd5730 100644 --- a/examples/get_token.rs +++ b/examples/get_token.rs @@ -7,23 +7,39 @@ const SCOPES: &str = #[tokio::main] async fn main() { - let session_config = SessionConfig::default(); + let mut session_config = SessionConfig::default(); let args: Vec<_> = env::args().collect(); - if args.len() != 3 { - eprintln!("Usage: {} USERNAME PASSWORD", args[0]); + if args.len() == 4 { + session_config.client_id = args[3].clone() + } else if args.len() != 3 { + eprintln!("Usage: {} USERNAME PASSWORD [CLIENT_ID]", args[0]); return; } + let username = &args[1]; + let password = &args[2]; - println!("Connecting..."); - let credentials = Credentials::with_password(&args[1], &args[2]); - let session = Session::new(session_config, None); + let session = Session::new(session_config.clone(), None); + let credentials = Credentials::with_password(username, password); + println!("Connecting with password.."); + let token = match session.connect(credentials, false).await { + Ok(()) => { + println!("Session username: {:#?}", session.username()); + session.token_provider().get_token(SCOPES).await.unwrap() + } + Err(e) => { + println!("Error connecting: {}", e); + return; + } + }; + println!("Token: {:#?}", token); + // Now create a new session with that token. + let session = Session::new(session_config, None); + let credentials = Credentials::with_access_token(username, token.access_token); + println!("Connecting with token.."); match session.connect(credentials, false).await { - Ok(()) => println!( - "Token: {:#?}", - session.token_provider().get_token(SCOPES).await.unwrap() - ), + Ok(()) => println!("Session username: {:#?}", session.username()), Err(e) => println!("Error connecting: {}", e), } } From 4956da9b8477a16cbcc8626e860baf5b2085d04f Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Mon, 16 Jan 2023 01:34:41 +0000 Subject: [PATCH 02/21] core: Credentials.username is optional. Isn't required for token auth. --- core/src/authentication.rs | 10 +++++----- core/src/connection/mod.rs | 12 +++++++----- core/src/session.rs | 8 ++++++-- examples/get_token.rs | 2 +- src/main.rs | 2 +- 5 files changed, 20 insertions(+), 14 deletions(-) mode change 100755 => 100644 core/src/session.rs diff --git a/core/src/authentication.rs b/core/src/authentication.rs index b2448ab2a..230661efd 100644 --- a/core/src/authentication.rs +++ b/core/src/authentication.rs @@ -29,7 +29,7 @@ impl From for Error { /// The credentials are used to log into the Spotify API. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct Credentials { - pub username: String, + pub username: Option, #[serde(serialize_with = "serialize_protobuf_enum")] #[serde(deserialize_with = "deserialize_protobuf_enum")] @@ -52,15 +52,15 @@ impl Credentials { /// ``` pub fn with_password(username: impl Into, password: impl Into) -> Self { Self { - username: username.into(), + username: Some(username.into()), auth_type: AuthenticationType::AUTHENTICATION_USER_PASS, auth_data: password.into().into_bytes(), } } - pub fn with_access_token(username: impl Into, token: impl Into) -> Self { + pub fn with_access_token(token: impl Into) -> Self { Self { - username: username.into(), + username: None, auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN, auth_data: token.into().into_bytes(), } @@ -145,7 +145,7 @@ impl Credentials { let auth_data = read_bytes(&mut cursor)?; Ok(Self { - username, + username: Some(username), auth_type, auth_data, }) diff --git a/core/src/connection/mod.rs b/core/src/connection/mod.rs index 03c5fec1f..b2e0356b9 100644 --- a/core/src/connection/mod.rs +++ b/core/src/connection/mod.rs @@ -99,10 +99,12 @@ pub async fn authenticate( }; let mut packet = ClientResponseEncrypted::new(); - packet - .login_credentials - .mut_or_insert_default() - .set_username(credentials.username); + if let Some(username) = credentials.username { + packet + .login_credentials + .mut_or_insert_default() + .set_username(username); + } packet .login_credentials .mut_or_insert_default() @@ -145,7 +147,7 @@ pub async fn authenticate( let welcome_data = APWelcome::parse_from_bytes(data.as_ref())?; let reusable_credentials = Credentials { - username: welcome_data.canonical_username().to_owned(), + username: Some(welcome_data.canonical_username().to_owned()), auth_type: welcome_data.reusable_auth_credentials_type(), auth_data: welcome_data.reusable_auth_credentials().to_owned(), }; diff --git a/core/src/session.rs b/core/src/session.rs old mode 100755 new mode 100644 index b8c55d205..85ce78950 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -172,8 +172,12 @@ impl Session { } }; - info!("Authenticated as \"{}\" !", reusable_credentials.username); - self.set_username(&reusable_credentials.username); + let username = reusable_credentials + .username + .as_ref() + .map_or("UNKNOWN", |s| s.as_str()); + info!("Authenticated as '{username}' !"); + self.set_username(username); if let Some(cache) = self.cache() { if store_credentials { let cred_changed = cache diff --git a/examples/get_token.rs b/examples/get_token.rs index 8a9cd5730..edc0a6026 100644 --- a/examples/get_token.rs +++ b/examples/get_token.rs @@ -36,7 +36,7 @@ async fn main() { // Now create a new session with that token. let session = Session::new(session_config, None); - let credentials = Credentials::with_access_token(username, token.access_token); + let credentials = Credentials::with_access_token(token.access_token); println!("Connecting with token.."); match session.connect(credentials, false).await { Ok(()) => println!("Session username: {:#?}", session.username()), diff --git a/src/main.rs b/src/main.rs index 3e656be2c..e9767046d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1095,7 +1095,7 @@ fn get_setup() -> Setup { Some(Credentials::with_password(username, password)) } else { match cached_creds { - Some(creds) if username == creds.username => Some(creds), + Some(creds) if Some(&username) == creds.username.as_ref() => Some(creds), _ => { let prompt = &format!("Password for {username}: "); match rpassword::prompt_password(prompt) { From eeec818d8345f900bee5bf211ee0f80195cfa788 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Wed, 14 Aug 2024 10:01:15 +0100 Subject: [PATCH 03/21] core: store auth data within session We might need this later if need to re-auth and original creds are no longer valid/available. --- core/src/session.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/core/src/session.rs b/core/src/session.rs index 85ce78950..251c84bd4 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -77,6 +77,7 @@ struct SessionData { client_brand_name: String, client_model_name: String, connection_id: String, + auth_data: Vec, time_delta: i64, invalid: bool, user_data: UserData, @@ -178,6 +179,7 @@ impl Session { .map_or("UNKNOWN", |s| s.as_str()); info!("Authenticated as '{username}' !"); self.set_username(username); + self.set_auth_data(&reusable_credentials.auth_data); if let Some(cache) = self.cache() { if store_credentials { let cred_changed = cache @@ -475,6 +477,14 @@ impl Session { username.clone_into(&mut self.0.data.write().user_data.canonical_username); } + pub fn auth_data(&self) -> Vec { + self.0.data.read().auth_data.clone() + } + + pub fn set_auth_data(&self, auth_data: &[u8]) { + self.0.data.write().auth_data = auth_data.to_owned(); + } + pub fn country(&self) -> String { self.0.data.read().user_data.country.clone() } From ee7ba28e8d86e6b1c3ec45c9ecd1a04626f9b066 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Tue, 6 Aug 2024 12:36:23 +0100 Subject: [PATCH 04/21] oauth: obtain Spotify access token via OAuth2 Sometimes there is also a username field returned with the token, but not always. It's nice to have but not needed (since we'll get it when we auth our session) and trying to extract it requires lots of boilerplate from the oauth lib. Let's keep it simple. --- Cargo.toml | 4 ++ oauth/Cargo.toml | 18 ++++++ oauth/src/lib.rs | 147 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 oauth/Cargo.toml create mode 100644 oauth/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index bb213136a..58c9810a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,10 @@ version = "0.5.0-dev" path = "protocol" version = "0.5.0-dev" +[dependencies.librespot-oauth] +path = "oauth" +version = "0.5.0-dev" + [dependencies] data-encoding = "2.5" env_logger = { version = "0.11.2", default-features = false, features = ["color", "humantime", "auto-color"] } diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml new file mode 100644 index 000000000..adeceb033 --- /dev/null +++ b/oauth/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "librespot-oauth" +version = "0.5.0-dev" +rust-version = "1.73" +authors = ["Paul Lietar "] +description = "Spotify OAuth" +license = "MIT" +repository = "https://github.com/librespot-org/librespot" +edition = "2021" + +[dependencies] +log = "0.4" +oauth2 = "4.4" +url = "2.2" + +[dependencies.librespot-core] +path = "../core" +version = "0.5.0-dev" \ No newline at end of file diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs new file mode 100644 index 000000000..a8e294128 --- /dev/null +++ b/oauth/src/lib.rs @@ -0,0 +1,147 @@ +use log::{debug, error, info, trace}; +use oauth2::reqwest::http_client; +use oauth2::{ + basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, + RedirectUrl, Scope, TokenResponse, TokenUrl, +}; +use std::io; +use std::{ + io::{BufRead, BufReader, Write}, + net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, + process::exit, + sync::mpsc, +}; +use url::Url; + +fn get_authcode_stdin() -> AuthorizationCode { + println!("Provide code"); + let mut buffer = String::new(); + let stdin = io::stdin(); // We get `Stdin` here. + stdin.read_line(&mut buffer).unwrap(); + + AuthorizationCode::new(buffer.trim().into()) +} + +fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode { + // A very naive implementation of the redirect server. + let listener = TcpListener::bind(socket_address).unwrap(); + + info!("OAuth server listening on {:?}", socket_address); + + // The server will terminate itself after collecting the first code. + let Some(mut stream) = listener.incoming().flatten().next() else { + panic!("listener terminated without accepting a connection"); + }; + + let mut reader = BufReader::new(&stream); + + let mut request_line = String::new(); + reader.read_line(&mut request_line).unwrap(); + + let redirect_url = request_line.split_whitespace().nth(1).unwrap(); + let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap(); + + let code = url + .query_pairs() + .find(|(key, _)| key == "code") + .map(|(_, code)| AuthorizationCode::new(code.into_owned())) + .unwrap(); + + let message = "Go back to your terminal :)"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", + message.len(), + message + ); + stream.write_all(response.as_bytes()).unwrap(); + + code +} + +// TODO: Return a Result? +// TODO: Pass in redirect_address instead since the redirect host depends on client ID? +pub fn get_access_token(client_id: &str, redirect_port: u16) -> String { + // Must use host 127.0.0.1 with Spotify Desktop client ID. + let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port); + let redirect_uri = format!("http://{redirect_address}/login"); + + let client = BasicClient::new( + ClientId::new(client_id.to_string()), + None, + AuthUrl::new("https://accounts.spotify.com/authorize".to_string()).unwrap(), + Some(TokenUrl::new("https://accounts.spotify.com/api/token".to_string()).unwrap()), + ) + .set_redirect_uri(RedirectUrl::new(redirect_uri).expect("Invalid redirect URL")); + + 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 scopes = vec![ + "app-remote-control", + "playlist-modify", + "playlist-modify-private", + "playlist-modify-public", + "playlist-read", + "playlist-read-collaborative", + "playlist-read-private", + "streaming", + "ugc-image-upload", + "user-follow-modify", + "user-follow-read", + "user-library-modify", + "user-library-read", + "user-modify", + "user-modify-playback-state", + "user-modify-private", + "user-personalized", + "user-read-birthdate", + "user-read-currently-playing", + "user-read-email", + "user-read-play-history", + "user-read-playback-position", + "user-read-playback-state", + "user-read-private", + "user-read-recently-played", + "user-top-read", + ]; + let scopes: Vec = scopes.into_iter().map(|s| Scope::new(s.into())).collect(); + let (auth_url, _) = client + .authorize_url(CsrfToken::new_random) + .add_scopes(scopes) + .set_pkce_challenge(pkce_challenge) + .url(); + + println!("Browse to: {}", auth_url); + + let code = if redirect_port > 0 { + get_authcode_listener(redirect_address) + } else { + get_authcode_stdin() + }; + debug!("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(); + let client_clone = client.clone(); + std::thread::spawn(move || { + let resp = client_clone + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request(http_client); + tx.send(resp).unwrap(); + }); + let token_response = rx.recv().unwrap(); + let token = match token_response { + Ok(tok) => { + trace!("Obtained new access token: {tok:?}"); + tok + } + Err(e) => { + error!("Failed to exchange code for access token: {e:?}"); + exit(1); + } + }; + + token.access_token().secret().to_string() +} From 60fae8fc7b08f09ff29e0b4aa20000c7cd673117 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Tue, 6 Aug 2024 12:42:17 +0100 Subject: [PATCH 05/21] bin: New --token arg for using Spotify access token Provide a token with sufficient scopes or empty string to obtain new token. When obtaining a new token, use --token-port argument to specify the redirect port. Specify 0 to manually enter the auth code (headless). Re-arranged setup function so have session_config earlier for use with get_access_token(). --- src/lib.rs | 1 + src/main.rs | 293 ++++++++++++++++++++++++++++++---------------------- 2 files changed, 169 insertions(+), 125 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 752112821..f6a176548 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,5 +5,6 @@ pub use librespot_connect as connect; pub use librespot_core as core; pub use librespot_discovery as discovery; pub use librespot_metadata as metadata; +pub use librespot_oauth as oauth; pub use librespot_playback as playback; pub use librespot_protocol as protocol; diff --git a/src/main.rs b/src/main.rs index e9767046d..c1242ef91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -234,6 +234,8 @@ fn get_setup() -> Setup { const QUIET: &str = "quiet"; const SYSTEM_CACHE: &str = "system-cache"; const TEMP_DIR: &str = "tmp"; + const TOKEN: &str = "token"; + const TOKEN_PORT: &str = "token-port"; const USERNAME: &str = "username"; const VERBOSE: &str = "verbose"; const VERSION: &str = "version"; @@ -276,6 +278,8 @@ fn get_setup() -> Setup { const ALSA_MIXER_INDEX_SHORT: &str = "s"; const ALSA_MIXER_CONTROL_SHORT: &str = "T"; const TEMP_DIR_SHORT: &str = "t"; + const TOKEN_SHORT: &str = "k"; + const TOKEN_PORT_SHORT: &str = "K"; const NORMALISATION_ATTACK_SHORT: &str = "U"; const USERNAME_SHORT: &str = "u"; const VERSION_SHORT: &str = "V"; @@ -415,6 +419,18 @@ fn get_setup() -> Setup { DEVICE_IS_GROUP, "Whether the device represents a group. Defaults to false.", ) + .optopt( + TOKEN_SHORT, + TOKEN, + "Spotify access token to sign in with. Use empty string to obtain token.", + "TOKEN", + ) + .optopt( + TOKEN_PORT_SHORT, + TOKEN_PORT, + "The port the oauth redirect server uses 1 - 65535. Ports <= 1024 may require root privileges.", + "PORT", + ) .optopt( TEMP_DIR_SHORT, TEMP_DIR, @@ -670,7 +686,10 @@ fn get_setup() -> Setup { trace!("Environment variable(s):"); for (k, v) in &env_vars { - if matches!(k.as_str(), "LIBRESPOT_PASSWORD" | "LIBRESPOT_USERNAME") { + if matches!( + k.as_str(), + "LIBRESPOT_PASSWORD" | "LIBRESPOT_USERNAME" | "LIBRESPOT_TOKEN" + ) { trace!("\t\t{k}=\"XXXXXXXX\""); } else if v.is_empty() { trace!("\t\t{k}="); @@ -702,7 +721,10 @@ fn get_setup() -> Setup { && matches.opt_defined(opt) && matches.opt_present(opt) { - if matches!(opt, PASSWORD | PASSWORD_SHORT | USERNAME | USERNAME_SHORT) { + if matches!( + opt, + PASSWORD | PASSWORD_SHORT | USERNAME | USERNAME_SHORT | TOKEN | TOKEN_SHORT + ) { // Don't log creds. trace!("\t\t{opt} \"XXXXXXXX\""); } else { @@ -1081,129 +1103,6 @@ fn get_setup() -> Setup { } }; - let credentials = { - let cached_creds = cache.as_ref().and_then(Cache::credentials); - - if let Some(username) = opt_str(USERNAME) { - if username.is_empty() { - empty_string_error_msg(USERNAME, USERNAME_SHORT); - } - if let Some(password) = opt_str(PASSWORD) { - if password.is_empty() { - empty_string_error_msg(PASSWORD, PASSWORD_SHORT); - } - Some(Credentials::with_password(username, password)) - } else { - match cached_creds { - Some(creds) if Some(&username) == creds.username.as_ref() => Some(creds), - _ => { - let prompt = &format!("Password for {username}: "); - match rpassword::prompt_password(prompt) { - Ok(password) => { - if !password.is_empty() { - Some(Credentials::with_password(username, password)) - } else { - trace!("Password was empty."); - if cached_creds.is_some() { - trace!("Using cached credentials."); - } - cached_creds - } - } - Err(e) => { - warn!("Cannot parse password: {}", e); - if cached_creds.is_some() { - trace!("Using cached credentials."); - } - cached_creds - } - } - } - } - } - } else { - if cached_creds.is_some() { - trace!("Using cached credentials."); - } - cached_creds - } - }; - - let enable_discovery = !opt_present(DISABLE_DISCOVERY); - - if credentials.is_none() && !enable_discovery { - error!("Credentials are required if discovery is disabled."); - exit(1); - } - - if !enable_discovery && opt_present(ZEROCONF_PORT) { - warn!( - "With the `--{}` / `-{}` flag set `--{}` / `-{}` has no effect.", - DISABLE_DISCOVERY, DISABLE_DISCOVERY_SHORT, ZEROCONF_PORT, ZEROCONF_PORT_SHORT - ); - } - - let zeroconf_port = if enable_discovery { - opt_str(ZEROCONF_PORT) - .map(|port| match port.parse::() { - Ok(value) if value != 0 => value, - _ => { - let valid_values = &format!("1 - {}", u16::MAX); - invalid_error_msg(ZEROCONF_PORT, ZEROCONF_PORT_SHORT, &port, valid_values, ""); - - exit(1); - } - }) - .unwrap_or(0) - } else { - 0 - }; - - // #1046: not all connections are supplied an `autoplay` user attribute to run statelessly. - // This knob allows for a manual override. - let autoplay = match opt_str(AUTOPLAY) { - Some(value) => match value.as_ref() { - "on" => Some(true), - "off" => Some(false), - _ => { - invalid_error_msg( - AUTOPLAY, - AUTOPLAY_SHORT, - &opt_str(AUTOPLAY).unwrap_or_default(), - "on, off", - "", - ); - exit(1); - } - }, - None => SessionConfig::default().autoplay, - }; - - let zeroconf_ip: Vec = if opt_present(ZEROCONF_INTERFACE) { - if let Some(zeroconf_ip) = opt_str(ZEROCONF_INTERFACE) { - zeroconf_ip - .split(',') - .map(|s| { - s.trim().parse::().unwrap_or_else(|_| { - invalid_error_msg( - ZEROCONF_INTERFACE, - ZEROCONF_INTERFACE_SHORT, - s, - "IPv4 and IPv6 addresses", - "", - ); - exit(1); - }) - }) - .collect() - } else { - warn!("Unable to use zeroconf-interface option, default to all interfaces."); - vec![] - } - } else { - vec![] - }; - let connect_config = { let connect_default_config = ConnectConfig::default(); @@ -1330,6 +1229,26 @@ fn get_setup() -> Setup { } }; + // #1046: not all connections are supplied an `autoplay` user attribute to run statelessly. + // This knob allows for a manual override. + let autoplay = match opt_str(AUTOPLAY) { + Some(value) => match value.as_ref() { + "on" => Some(true), + "off" => Some(false), + _ => { + invalid_error_msg( + AUTOPLAY, + AUTOPLAY_SHORT, + &opt_str(AUTOPLAY).unwrap_or_default(), + "on, off", + "", + ); + exit(1); + } + }, + None => SessionConfig::default().autoplay, + }; + let session_config = SessionConfig { device_id: device_id(&connect_config.name), proxy: opt_str(PROXY).or_else(|| std::env::var("http_proxy").ok()).map( @@ -1364,6 +1283,130 @@ fn get_setup() -> Setup { ..SessionConfig::default() }; + let credentials = { + let cached_creds = cache.as_ref().and_then(Cache::credentials); + + let token_port = if opt_present(TOKEN_PORT) { + opt_str(TOKEN_PORT) + .map(|port| match port.parse::() { + Ok(value) => value, + _ => { + let valid_values = &format!("1 - {}", u16::MAX); + invalid_error_msg(TOKEN_PORT, TOKEN_PORT_SHORT, &port, valid_values, ""); + + exit(1); + } + }) + .unwrap_or(0) + } else { + 5588 + }; + if let Some(mut access_token) = opt_str(TOKEN) { + if access_token.is_empty() { + access_token = + librespot::oauth::get_access_token(&session_config.client_id, token_port); + } + Some(Credentials::with_access_token(access_token)) + } else if let Some(username) = opt_str(USERNAME) { + if username.is_empty() { + empty_string_error_msg(USERNAME, USERNAME_SHORT); + } + if let Some(password) = opt_str(PASSWORD) { + if password.is_empty() { + empty_string_error_msg(PASSWORD, PASSWORD_SHORT); + } + Some(Credentials::with_password(username, password)) + } else { + match cached_creds { + Some(creds) if Some(&username) == creds.username.as_ref() => Some(creds), + _ => { + let prompt = &format!("Password for {username}: "); + match rpassword::prompt_password(prompt) { + Ok(password) => { + if !password.is_empty() { + Some(Credentials::with_password(username, password)) + } else { + trace!("Password was empty."); + if cached_creds.is_some() { + trace!("Using cached credentials."); + } + cached_creds + } + } + Err(e) => { + warn!("Cannot parse password: {}", e); + if cached_creds.is_some() { + trace!("Using cached credentials."); + } + cached_creds + } + } + } + } + } + } else { + if cached_creds.is_some() { + trace!("Using cached credentials."); + } + cached_creds + } + }; + + let enable_discovery = !opt_present(DISABLE_DISCOVERY); + + if credentials.is_none() && !enable_discovery { + error!("Credentials are required if discovery is disabled."); + exit(1); + } + + if !enable_discovery && opt_present(ZEROCONF_PORT) { + warn!( + "With the `--{}` / `-{}` flag set `--{}` / `-{}` has no effect.", + DISABLE_DISCOVERY, DISABLE_DISCOVERY_SHORT, ZEROCONF_PORT, ZEROCONF_PORT_SHORT + ); + } + + let zeroconf_port = if enable_discovery { + opt_str(ZEROCONF_PORT) + .map(|port| match port.parse::() { + Ok(value) if value != 0 => value, + _ => { + let valid_values = &format!("1 - {}", u16::MAX); + invalid_error_msg(ZEROCONF_PORT, ZEROCONF_PORT_SHORT, &port, valid_values, ""); + + exit(1); + } + }) + .unwrap_or(0) + } else { + 0 + }; + + let zeroconf_ip: Vec = if opt_present(ZEROCONF_INTERFACE) { + if let Some(zeroconf_ip) = opt_str(ZEROCONF_INTERFACE) { + zeroconf_ip + .split(',') + .map(|s| { + s.trim().parse::().unwrap_or_else(|_| { + invalid_error_msg( + ZEROCONF_INTERFACE, + ZEROCONF_INTERFACE_SHORT, + s, + "IPv4 and IPv6 addresses", + "", + ); + exit(1); + }) + }) + .collect() + } else { + warn!("Unable to use zeroconf-interface option, default to all interfaces."); + vec![] + } + } else { + vec![] + }; + let player_config = { let player_default_config = PlayerConfig::default(); From fe4d36bfb003ee9bdf9f86fafd03bce760e73da7 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Wed, 14 Aug 2024 12:29:31 +0100 Subject: [PATCH 06/21] oauth: break-out code parsing --- oauth/src/lib.rs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index a8e294128..911f36217 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -13,13 +13,23 @@ use std::{ }; use url::Url; +fn get_code(redirect_url: &str) -> AuthorizationCode { + let url = Url::parse(redirect_url).unwrap(); + let code = url + .query_pairs() + .find(|(key, _)| key == "code") + .map(|(_, code)| AuthorizationCode::new(code.into_owned())) + .unwrap(); + code +} + fn get_authcode_stdin() -> AuthorizationCode { - println!("Provide code"); + println!("Provide redirect URL"); let mut buffer = String::new(); let stdin = io::stdin(); // We get `Stdin` here. stdin.read_line(&mut buffer).unwrap(); - AuthorizationCode::new(buffer.trim().into()) + get_code(buffer.trim()) } fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode { @@ -39,13 +49,7 @@ fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode { reader.read_line(&mut request_line).unwrap(); let redirect_url = request_line.split_whitespace().nth(1).unwrap(); - let url = Url::parse(&("http://localhost".to_string() + redirect_url)).unwrap(); - - let code = url - .query_pairs() - .find(|(key, _)| key == "code") - .map(|(_, code)| AuthorizationCode::new(code.into_owned())) - .unwrap(); + let code = get_code(&("http://localhost".to_string() + redirect_url)); let message = "Go back to your terminal :)"; let response = format!( @@ -60,6 +64,7 @@ fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode { // TODO: Return a Result? // TODO: Pass in redirect_address instead since the redirect host depends on client ID? +// TODO: Pass in scopes. pub fn get_access_token(client_id: &str, redirect_port: u16) -> String { // Must use host 127.0.0.1 with Spotify Desktop client ID. let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port); From 65a252627f95f6ecf2b4c532b6afd773c477f52c Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Fri, 16 Aug 2024 01:33:02 +0100 Subject: [PATCH 07/21] Updated token example --- examples/get_token.rs | 52 +++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/examples/get_token.rs b/examples/get_token.rs index edc0a6026..0562b0b20 100644 --- a/examples/get_token.rs +++ b/examples/get_token.rs @@ -1,6 +1,7 @@ use std::env; use librespot::core::{authentication::Credentials, config::SessionConfig, session::Session}; +use librespot::protocol::authentication::AuthenticationType; const SCOPES: &str = "streaming,user-read-playback-state,user-modify-playback-state,user-read-currently-playing"; @@ -10,36 +11,43 @@ async fn main() { let mut session_config = SessionConfig::default(); let args: Vec<_> = env::args().collect(); - if args.len() == 4 { - session_config.client_id = args[3].clone() - } else if args.len() != 3 { - eprintln!("Usage: {} USERNAME PASSWORD [CLIENT_ID]", args[0]); + if args.len() == 3 { + session_config.client_id = args[2].clone() + } else if args.len() != 2 { + eprintln!("Usage: {} ACCESS_TOKEN [CLIENT_ID]", args[0]); return; } - let username = &args[1]; - let password = &args[2]; + let access_token = &args[1]; + // Now create a new session with that token. let session = Session::new(session_config.clone(), None); - let credentials = Credentials::with_password(username, password); - println!("Connecting with password.."); - let token = match session.connect(credentials, false).await { - Ok(()) => { - println!("Session username: {:#?}", session.username()); - session.token_provider().get_token(SCOPES).await.unwrap() + let credentials = Credentials::with_access_token(access_token); + println!("Connecting with token.."); + match session.connect(credentials, false).await { + Ok(()) => println!("Session username: {:#?}", session.username()), + Err(e) => { + println!("Error connecting: {e}"); + return; } + }; + + // This will fail. You can't use keymaster from an access token authed session. + // let token = session.token_provider().get_token(SCOPES).await.unwrap(); + + // Instead, derive stored credentials and auth a new session using those. + let stored_credentials = Credentials { + username: Some(session.username()), + auth_type: AuthenticationType::AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS, + auth_data: session.auth_data(), + }; + let session2 = Session::new(session_config, None); + match session2.connect(stored_credentials, false).await { + Ok(()) => println!("Session username: {:#?}", session2.username()), Err(e) => { println!("Error connecting: {}", e); return; } }; - println!("Token: {:#?}", token); - - // Now create a new session with that token. - let session = Session::new(session_config, None); - let credentials = Credentials::with_access_token(token.access_token); - println!("Connecting with token.."); - match session.connect(credentials, false).await { - Ok(()) => println!("Session username: {:#?}", session.username()), - Err(e) => println!("Error connecting: {}", e), - } + let token = session2.token_provider().get_token(SCOPES).await.unwrap(); + println!("Got me a token: {token:#?}"); } From 70e2e5ffb1b6bf95304977eabcce91824e069aa8 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Mon, 26 Aug 2024 21:49:48 +0100 Subject: [PATCH 08/21] oauth: Update crate documentation --- oauth/Cargo.toml | 4 ++-- oauth/src/lib.rs | 12 ++++++++++++ publish.sh | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml index adeceb033..e04faa580 100644 --- a/oauth/Cargo.toml +++ b/oauth/Cargo.toml @@ -2,8 +2,8 @@ name = "librespot-oauth" version = "0.5.0-dev" rust-version = "1.73" -authors = ["Paul Lietar "] -description = "Spotify OAuth" +authors = ["Nick Steel "] +description = "OAuth authorization code flow with PKCE for obtaining a Spotify access token" license = "MIT" repository = "https://github.com/librespot-org/librespot" edition = "2021" diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 911f36217..482f8d700 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -1,3 +1,15 @@ +//! Provides a Spotify access token using the OAuth authorization code flow +//! with PKCE. +//! +//! Assuming sufficient scopes, the returned access token may be used with Spotify's +//! Web API, and/or to establish a new Session with [`librespot_core`]. +//! +//! The authorization code flow is an interactive process which requires a web browser +//! to complete. The resulting code must then be provided back from the browser to this +//! library for exchange into an access token. Providing the code can be automatic via +//! a spawned http server (mimicking Spotify's client), or manually via stdin. The latter +//! is appropriate for headless systems. + use log::{debug, error, info, trace}; use oauth2::reqwest::http_client; use oauth2::{ diff --git a/publish.sh b/publish.sh index c39f1c960..c9982c97c 100755 --- a/publish.sh +++ b/publish.sh @@ -6,7 +6,7 @@ DRY_RUN='false' WORKINGDIR="$( cd "$(dirname "$0")" ; pwd -P )" cd $WORKINGDIR -crates=( "protocol" "core" "discovery" "audio" "metadata" "playback" "connect" "librespot" ) +crates=( "protocol" "core" "discovery" "oauth" "audio" "metadata" "playback" "connect" "librespot" ) OS=`uname` function replace_in_file() { From 2354d77f8ee67f7157868b4f06d7fa2bca70e55e Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Thu, 29 Aug 2024 01:14:14 +0100 Subject: [PATCH 09/21] ouath: error handling, pass scopes as param, redirect_port is Option --- oauth/Cargo.toml | 1 + oauth/src/lib.rs | 182 +++++++++++++++++++++++++++++------------------ src/main.rs | 56 +++++++++++++-- 3 files changed, 163 insertions(+), 76 deletions(-) diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml index e04faa580..af02f04d8 100644 --- a/oauth/Cargo.toml +++ b/oauth/Cargo.toml @@ -11,6 +11,7 @@ edition = "2021" [dependencies] log = "0.4" oauth2 = "4.4" +thiserror = "1.0" url = "2.2" [dependencies.librespot-core] diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 482f8d700..4e1ad5816 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -20,47 +20,104 @@ use std::io; use std::{ io::{BufRead, BufReader, Write}, net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, - process::exit, sync::mpsc, }; +use thiserror::Error; use url::Url; -fn get_code(redirect_url: &str) -> AuthorizationCode { - let url = Url::parse(redirect_url).unwrap(); +#[derive(Debug, Error)] +pub enum OAuthError { + #[error("Unable to parse redirect URI {uri} ({e})")] + AuthCodeBadUri { uri: String, e: url::ParseError }, + + #[error("Auth code param not found in URI {uri}")] + AuthCodeNotFound { uri: String }, + + #[error("Failed to read redirect URI from stdin")] + AuthCodeStdinRead, + + #[error("Failed to bind server to {addr} ({e})")] + AuthCodeListenerBind { addr: SocketAddr, e: io::Error }, + + #[error("Listener terminated without accepting a connection")] + AuthCodeListenerTerminated, + + #[error("Failed to read redirect URI from HTTP request")] + AuthCodeListenerRead, + + #[error("Failed to parse redirect URI from HTTP request")] + AuthCodeListenerParse, + + #[error("Failed to write HTTP response")] + AuthCodeListenerWrite, + + #[error("Invalid Spotify OAuth URI")] + InvalidSpotifyUri, + + #[error("Invalid Redirect URI {uri} ({e})")] + InvalidRedirectUri { uri: String, e: url::ParseError }, + + #[error("Failed to recieve code")] + Recv, + + #[error("Failed to exchange code for access token ({e})")] + ExchangeCode { e: String }, +} + +/// 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 { + uri: redirect_url.to_string(), + e, + })?; let code = url .query_pairs() .find(|(key, _)| key == "code") .map(|(_, code)| AuthorizationCode::new(code.into_owned())) - .unwrap(); - code + .ok_or(OAuthError::AuthCodeNotFound { + uri: redirect_url.to_string(), + })?; + + Ok(code) } -fn get_authcode_stdin() -> AuthorizationCode { +/// Prompt for redirect URI on stdin and return auth code. +fn get_authcode_stdin() -> Result { println!("Provide redirect URL"); let mut buffer = String::new(); - let stdin = io::stdin(); // We get `Stdin` here. - stdin.read_line(&mut buffer).unwrap(); + let stdin = io::stdin(); + stdin + .read_line(&mut buffer) + .map_err(|_| OAuthError::AuthCodeStdinRead)?; get_code(buffer.trim()) } -fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode { - // A very naive implementation of the redirect server. - let listener = TcpListener::bind(socket_address).unwrap(); - +/// Spawn HTTP server on provided socket to accept OAuth callback and return auth code. +fn get_authcode_listener(socket_address: SocketAddr) -> Result { + let listener = + TcpListener::bind(socket_address).map_err(|e| OAuthError::AuthCodeListenerBind { + addr: socket_address, + e, + })?; info!("OAuth server listening on {:?}", socket_address); // The server will terminate itself after collecting the first code. - let Some(mut stream) = listener.incoming().flatten().next() else { - panic!("listener terminated without accepting a connection"); - }; - + let mut stream = listener + .incoming() + .flatten() + .next() + .ok_or(OAuthError::AuthCodeListenerTerminated)?; let mut reader = BufReader::new(&stream); - let mut request_line = String::new(); - reader.read_line(&mut request_line).unwrap(); - - let redirect_url = request_line.split_whitespace().nth(1).unwrap(); + reader + .read_line(&mut request_line) + .map_err(|_| OAuthError::AuthCodeListenerRead)?; + + let redirect_url = request_line + .split_whitespace() + .nth(1) + .ok_or(OAuthError::AuthCodeListenerParse)?; let code = get_code(&("http://localhost".to_string() + redirect_url)); let message = "Go back to your terminal :)"; @@ -69,59 +126,48 @@ fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode { message.len(), message ); - stream.write_all(response.as_bytes()).unwrap(); + stream + .write_all(response.as_bytes()) + .map_err(|_| OAuthError::AuthCodeListenerWrite)?; code } -// TODO: Return a Result? // TODO: Pass in redirect_address instead since the redirect host depends on client ID? -// TODO: Pass in scopes. -pub fn get_access_token(client_id: &str, redirect_port: u16) -> String { +/// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. +pub fn get_access_token( + client_id: &str, + scopes: Vec<&str>, + redirect_port: Option, +) -> Result { // Must use host 127.0.0.1 with Spotify Desktop client ID. - let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port); + let redirect_address = SocketAddr::new( + IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), + redirect_port.unwrap_or_default(), + ); let redirect_uri = format!("http://{redirect_address}/login"); + 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 client = BasicClient::new( ClientId::new(client_id.to_string()), None, - AuthUrl::new("https://accounts.spotify.com/authorize".to_string()).unwrap(), - Some(TokenUrl::new("https://accounts.spotify.com/api/token".to_string()).unwrap()), + auth_url, + Some(token_url), ) - .set_redirect_uri(RedirectUrl::new(redirect_uri).expect("Invalid redirect URL")); + .set_redirect_uri(RedirectUrl::new(redirect_uri.clone()).map_err(|e| { + OAuthError::InvalidRedirectUri { + uri: redirect_uri, + e, + } + })?); 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 scopes = vec![ - "app-remote-control", - "playlist-modify", - "playlist-modify-private", - "playlist-modify-public", - "playlist-read", - "playlist-read-collaborative", - "playlist-read-private", - "streaming", - "ugc-image-upload", - "user-follow-modify", - "user-follow-read", - "user-library-modify", - "user-library-read", - "user-modify", - "user-modify-playback-state", - "user-modify-private", - "user-personalized", - "user-read-birthdate", - "user-read-currently-playing", - "user-read-email", - "user-read-play-history", - "user-read-playback-position", - "user-read-playback-state", - "user-read-private", - "user-read-recently-played", - "user-top-read", - ]; let scopes: Vec = scopes.into_iter().map(|s| Scope::new(s.into())).collect(); let (auth_url, _) = client .authorize_url(CsrfToken::new_random) @@ -131,11 +177,11 @@ pub fn get_access_token(client_id: &str, redirect_port: u16) -> String { println!("Browse to: {}", auth_url); - let code = if redirect_port > 0 { + let code = if redirect_port.is_some() { get_authcode_listener(redirect_address) } else { get_authcode_stdin() - }; + }?; debug!("Exchange {code:?} for access token"); // Do this sync in another thread because I am too stupid to make the async version work. @@ -146,19 +192,13 @@ pub fn get_access_token(client_id: &str, redirect_port: u16) -> String { .exchange_code(code) .set_pkce_verifier(pkce_verifier) .request(http_client); - tx.send(resp).unwrap(); - }); - let token_response = rx.recv().unwrap(); - let token = match token_response { - Ok(tok) => { - trace!("Obtained new access token: {tok:?}"); - tok - } - Err(e) => { - error!("Failed to exchange code for access token: {e:?}"); - exit(1); + 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:?}"); - token.access_token().secret().to_string() + Ok(token.access_token().secret().to_string()) } diff --git a/src/main.rs b/src/main.rs index c1242ef91..e872bbd84 100644 --- a/src/main.rs +++ b/src/main.rs @@ -168,6 +168,37 @@ fn get_version_string() -> String { ) } +/// Spotify's Desktop app uses these. Some of these are only available when requested with Spotify's client IDs. +static OAUTH_SCOPES: &[&str] = &[ + //const OAUTH_SCOPES: Vec<&str> = vec![ + "app-remote-control", + "playlist-modify", + "playlist-modify-private", + "playlist-modify-public", + "playlist-read", + "playlist-read-collaborative", + "playlist-read-private", + "streaming", + "ugc-image-upload", + "user-follow-modify", + "user-follow-read", + "user-library-modify", + "user-library-read", + "user-modify", + "user-modify-playback-state", + "user-modify-private", + "user-personalized", + "user-read-birthdate", + "user-read-currently-playing", + "user-read-email", + "user-read-play-history", + "user-read-playback-position", + "user-read-playback-state", + "user-read-private", + "user-read-recently-played", + "user-top-read", +]; + struct Setup { format: AudioFormat, backend: SinkBuilder, @@ -1289,7 +1320,13 @@ fn get_setup() -> Setup { let token_port = if opt_present(TOKEN_PORT) { opt_str(TOKEN_PORT) .map(|port| match port.parse::() { - Ok(value) => value, + Ok(value) => { + if value > 0 { + Some(value) + } else { + None + } + } _ => { let valid_values = &format!("1 - {}", u16::MAX); invalid_error_msg(TOKEN_PORT, TOKEN_PORT_SHORT, &port, valid_values, ""); @@ -1297,14 +1334,23 @@ fn get_setup() -> Setup { exit(1); } }) - .unwrap_or(0) + .unwrap_or(None) } else { - 5588 + Some(5588) }; if let Some(mut access_token) = opt_str(TOKEN) { if access_token.is_empty() { - access_token = - librespot::oauth::get_access_token(&session_config.client_id, token_port); + access_token = match librespot::oauth::get_access_token( + &session_config.client_id, + OAUTH_SCOPES.to_vec(), + token_port, + ) { + Ok(token) => token, + Err(e) => { + error!("Failed to get Spotify access token: {e}"); + exit(1); + } + }; } Some(Credentials::with_access_token(access_token)) } else if let Some(username) = opt_str(USERNAME) { From e5f4e68e31d7a2a58f66bc1b86ff43de4d558a76 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Thu, 29 Aug 2024 02:28:41 +0100 Subject: [PATCH 10/21] oauth: Return type --- oauth/src/lib.rs | 29 +++++++++++++++++++++++++---- src/main.rs | 2 +- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 4e1ad5816..7d1aa7b46 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -17,6 +17,7 @@ use oauth2::{ RedirectUrl, Scope, TokenResponse, TokenUrl, }; use std::io; +use std::time::{Duration, Instant}; use std::{ io::{BufRead, BufReader, Write}, net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, @@ -64,6 +65,15 @@ pub enum OAuthError { ExchangeCode { e: String }, } +#[derive(Debug)] +pub struct AccessToken { + pub access_token: String, + pub refresh_token: String, + pub expires_at: Instant, + pub token_type: String, + pub scopes: Vec, +} + /// 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 { @@ -139,7 +149,7 @@ pub fn get_access_token( client_id: &str, scopes: Vec<&str>, redirect_port: Option, -) -> Result { +) -> Result { // Must use host 127.0.0.1 with Spotify Desktop client ID. let redirect_address = SocketAddr::new( IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), @@ -168,10 +178,10 @@ pub fn get_access_token( // Generate the full authorization URL. // Some of these scopes are unavailable for custom client IDs. Which? - let scopes: Vec = scopes.into_iter().map(|s| Scope::new(s.into())).collect(); + let request_scopes: Vec = scopes.clone().into_iter().map(|s| Scope::new(s.into())).collect(); let (auth_url, _) = client .authorize_url(CsrfToken::new_random) - .add_scopes(scopes) + .add_scopes(request_scopes) .set_pkce_challenge(pkce_challenge) .url(); @@ -200,5 +210,16 @@ pub fn get_access_token( let token = token_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; trace!("Obtained new access token: {token:?}"); - Ok(token.access_token().secret().to_string()) + let token_scopes: Vec = match token.scopes() { + Some(s) => s.into_iter().map(|s| s.to_string()).collect(), + _ => scopes.into_iter().map(|s| s.to_string()).collect(), + }; + Ok( + AccessToken { + access_token: token.access_token().secret().to_string(), + refresh_token: token.refresh_token().unwrap().secret().to_string(), + expires_at: Instant::now() + token.expires_in().unwrap_or(Duration::from_secs(3600)), + token_type: format!("{:?}", token.token_type()).to_string(), // Urgh!? + scopes: token_scopes, + }) } diff --git a/src/main.rs b/src/main.rs index e872bbd84..de4d7c644 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1345,7 +1345,7 @@ fn get_setup() -> Setup { OAUTH_SCOPES.to_vec(), token_port, ) { - Ok(token) => token, + Ok(token) => token.access_token, Err(e) => { error!("Failed to get Spotify access token: {e}"); exit(1); From da0f6a10ab9d9e359c8cf3249ef0e6ab2b6e0c90 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Thu, 29 Aug 2024 02:31:47 +0100 Subject: [PATCH 11/21] oauth: provide example --- oauth/Cargo.toml | 5 ++++- oauth/examples/oauth.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 oauth/examples/oauth.rs diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml index af02f04d8..a8a20a79b 100644 --- a/oauth/Cargo.toml +++ b/oauth/Cargo.toml @@ -16,4 +16,7 @@ url = "2.2" [dependencies.librespot-core] path = "../core" -version = "0.5.0-dev" \ No newline at end of file +version = "0.5.0-dev" + +[dev-dependencies] +env_logger = { version = "0.11.2", default-features = false, features = ["color", "humantime", "auto-color"] } \ No newline at end of file diff --git a/oauth/examples/oauth.rs b/oauth/examples/oauth.rs new file mode 100644 index 000000000..373c64513 --- /dev/null +++ b/oauth/examples/oauth.rs @@ -0,0 +1,13 @@ +use librespot_core::SessionConfig; +use librespot_oauth::get_access_token; + +fn main() { + let mut builder = env_logger::Builder::new(); + builder.parse_filters("librespot=trace"); + builder.init(); + + match get_access_token(&SessionConfig::default().client_id, vec!["streaming"], Some(1337)) { + Ok(token) => println!("Success: {token:#?}"), + Err(e) => println!("Failed: {e}"), + }; +} \ No newline at end of file From e6843d921fd6ae22f07ff60a61244f43204440e7 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Thu, 29 Aug 2024 02:33:24 +0100 Subject: [PATCH 12/21] fix formatting --- oauth/examples/oauth.rs | 10 +++++++--- oauth/src/lib.rs | 11 +++++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/oauth/examples/oauth.rs b/oauth/examples/oauth.rs index 373c64513..86e1453fc 100644 --- a/oauth/examples/oauth.rs +++ b/oauth/examples/oauth.rs @@ -5,9 +5,13 @@ fn main() { let mut builder = env_logger::Builder::new(); builder.parse_filters("librespot=trace"); builder.init(); - - match get_access_token(&SessionConfig::default().client_id, vec!["streaming"], Some(1337)) { + + match get_access_token( + &SessionConfig::default().client_id, + vec!["streaming"], + Some(1337), + ) { Ok(token) => println!("Success: {token:#?}"), Err(e) => println!("Failed: {e}"), }; -} \ No newline at end of file +} diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 7d1aa7b46..27b596a10 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -178,7 +178,11 @@ pub fn get_access_token( // Generate the full authorization URL. // Some of these scopes are unavailable for custom client IDs. Which? - let request_scopes: Vec = scopes.clone().into_iter().map(|s| Scope::new(s.into())).collect(); + let request_scopes: Vec = scopes + .clone() + .into_iter() + .map(|s| Scope::new(s.into())) + .collect(); let (auth_url, _) = client .authorize_url(CsrfToken::new_random) .add_scopes(request_scopes) @@ -211,11 +215,10 @@ pub fn get_access_token( trace!("Obtained new access token: {token:?}"); let token_scopes: Vec = match token.scopes() { - Some(s) => s.into_iter().map(|s| s.to_string()).collect(), + Some(s) => s.iter().map(|s| s.to_string()).collect(), _ => scopes.into_iter().map(|s| s.to_string()).collect(), }; - Ok( - AccessToken { + Ok(AccessToken { access_token: token.access_token().secret().to_string(), refresh_token: token.refresh_token().unwrap().secret().to_string(), expires_at: Instant::now() + token.expires_in().unwrap_or(Duration::from_secs(3600)), From 0a1fe356ad72e0c19204c27bc02c174a24646900 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Wed, 4 Sep 2024 00:21:20 +0100 Subject: [PATCH 13/21] bin: move oauth into main function Added --enable-oauth / -j option. Using --password / -p option will error and exit. --- src/main.rs | 364 +++++++++++++++++++++++++++------------------------- 1 file changed, 187 insertions(+), 177 deletions(-) diff --git a/src/main.rs b/src/main.rs index de4d7c644..7df99a093 100644 --- a/src/main.rs +++ b/src/main.rs @@ -210,6 +210,8 @@ struct Setup { connect_config: ConnectConfig, mixer_config: MixerConfig, credentials: Option, + enable_oauth: bool, + oauth_port: Option, enable_discovery: bool, zeroconf_port: u16, player_event_program: Option, @@ -226,6 +228,7 @@ fn get_setup() -> Setup { const VALID_NORMALISATION_ATTACK_RANGE: RangeInclusive = 1..=500; const VALID_NORMALISATION_RELEASE_RANGE: RangeInclusive = 1..=1000; + const ACCESS_TOKEN: &str = "access-token"; const AP_PORT: &str = "ap-port"; const AUTOPLAY: &str = "autoplay"; const BACKEND: &str = "backend"; @@ -241,6 +244,7 @@ fn get_setup() -> Setup { const DISABLE_GAPLESS: &str = "disable-gapless"; const DITHER: &str = "dither"; const EMIT_SINK_EVENTS: &str = "emit-sink-events"; + const ENABLE_OAUTH: &str = "enable-oauth"; const ENABLE_VOLUME_NORMALISATION: &str = "enable-volume-normalisation"; const FORMAT: &str = "format"; const HELP: &str = "help"; @@ -257,6 +261,7 @@ fn get_setup() -> Setup { const NORMALISATION_PREGAIN: &str = "normalisation-pregain"; const NORMALISATION_RELEASE: &str = "normalisation-release"; const NORMALISATION_THRESHOLD: &str = "normalisation-threshold"; + const OAUTH_PORT: &str = "oauth-port"; const ONEVENT: &str = "onevent"; #[cfg(feature = "passthrough-decoder")] const PASSTHROUGH: &str = "passthrough"; @@ -265,8 +270,6 @@ fn get_setup() -> Setup { const QUIET: &str = "quiet"; const SYSTEM_CACHE: &str = "system-cache"; const TEMP_DIR: &str = "tmp"; - const TOKEN: &str = "token"; - const TOKEN_PORT: &str = "token-port"; const USERNAME: &str = "username"; const VERBOSE: &str = "verbose"; const VERSION: &str = "version"; @@ -293,6 +296,9 @@ fn get_setup() -> Setup { const DISABLE_CREDENTIAL_CACHE_SHORT: &str = "H"; const HELP_SHORT: &str = "h"; const ZEROCONF_INTERFACE_SHORT: &str = "i"; + const ENABLE_OAUTH_SHORT: &str = "j"; + const OAUTH_PORT_SHORT: &str = "K"; + const ACCESS_TOKEN_SHORT: &str = "k"; const CACHE_SIZE_LIMIT_SHORT: &str = "M"; const MIXER_TYPE_SHORT: &str = "m"; const ENABLE_VOLUME_NORMALISATION_SHORT: &str = "N"; @@ -309,8 +315,6 @@ fn get_setup() -> Setup { const ALSA_MIXER_INDEX_SHORT: &str = "s"; const ALSA_MIXER_CONTROL_SHORT: &str = "T"; const TEMP_DIR_SHORT: &str = "t"; - const TOKEN_SHORT: &str = "k"; - const TOKEN_PORT_SHORT: &str = "K"; const NORMALISATION_ATTACK_SHORT: &str = "U"; const USERNAME_SHORT: &str = "u"; const VERSION_SHORT: &str = "V"; @@ -416,6 +420,11 @@ fn get_setup() -> Setup { ENABLE_VOLUME_NORMALISATION, "Play all tracks at approximately the same apparent volume.", ) + .optflag( + ENABLE_OAUTH_SHORT, + ENABLE_OAUTH, + "Perform interactive OAuth sign in.", + ) .optopt( NAME_SHORT, NAME, @@ -450,18 +459,6 @@ fn get_setup() -> Setup { DEVICE_IS_GROUP, "Whether the device represents a group. Defaults to false.", ) - .optopt( - TOKEN_SHORT, - TOKEN, - "Spotify access token to sign in with. Use empty string to obtain token.", - "TOKEN", - ) - .optopt( - TOKEN_PORT_SHORT, - TOKEN_PORT, - "The port the oauth redirect server uses 1 - 65535. Ports <= 1024 may require root privileges.", - "PORT", - ) .optopt( TEMP_DIR_SHORT, TEMP_DIR, @@ -504,6 +501,18 @@ fn get_setup() -> Setup { "Password used to sign in with.", "PASSWORD", ) + .optopt( + ACCESS_TOKEN_SHORT, + ACCESS_TOKEN, + "Spotify access token to sign in with.", + "TOKEN", + ) + .optopt( + OAUTH_PORT_SHORT, + OAUTH_PORT, + "The port the oauth redirect server uses 1 - 65535. Ports <= 1024 may require root privileges.", + "PORT", + ) .optopt( ONEVENT_SHORT, ONEVENT, @@ -719,7 +728,7 @@ fn get_setup() -> Setup { for (k, v) in &env_vars { if matches!( k.as_str(), - "LIBRESPOT_PASSWORD" | "LIBRESPOT_USERNAME" | "LIBRESPOT_TOKEN" + "LIBRESPOT_PASSWORD" | "LIBRESPOT_USERNAME" | "LIBRESPOT_ACCESS_TOKEN" ) { trace!("\t\t{k}=\"XXXXXXXX\""); } else if v.is_empty() { @@ -754,7 +763,12 @@ fn get_setup() -> Setup { { if matches!( opt, - PASSWORD | PASSWORD_SHORT | USERNAME | USERNAME_SHORT | TOKEN | TOKEN_SHORT + PASSWORD + | PASSWORD_SHORT + | USERNAME + | USERNAME_SHORT + | ACCESS_TOKEN + | ACCESS_TOKEN_SHORT ) { // Don't log creds. trace!("\t\t{opt} \"XXXXXXXX\""); @@ -1134,6 +1148,145 @@ fn get_setup() -> Setup { } }; + let enable_oauth = opt_present(ENABLE_OAUTH); + + let credentials = { + let cached_creds = cache.as_ref().and_then(Cache::credentials); + + if let Some(access_token) = opt_str(ACCESS_TOKEN) { + if access_token.is_empty() { + empty_string_error_msg(ACCESS_TOKEN, ACCESS_TOKEN_SHORT); + } + Some(Credentials::with_access_token(access_token)) + } else if let Some(username) = opt_str(USERNAME) { + if username.is_empty() { + empty_string_error_msg(USERNAME, USERNAME_SHORT); + } + if opt_present(PASSWORD) { + error!("Invalid `--{PASSWORD}` / `-{PASSWORD_SHORT}`: Password authentication no longer supported, use OAuth"); + exit(1); + } + match cached_creds { + Some(creds) if Some(username) == creds.username => { + trace!("Using cached credentials for specified username."); + Some(creds) + } + _ => { + trace!("No cached credentials for specified username."); + None + } + } + } else { + if cached_creds.is_some() { + trace!("Using cached credentials."); + } + cached_creds + } + }; + + let enable_discovery = !opt_present(DISABLE_DISCOVERY); + + if credentials.is_none() && !enable_discovery && !enable_oauth { + error!("Credentials are required if discovery and oauth login are disabled."); + exit(1); + } + + let oauth_port = if opt_present(OAUTH_PORT) { + if !enable_oauth { + warn!( + "Without the `--{}` / `-{}` flag set `--{}` / `-{}` has no effect.", + ENABLE_OAUTH, ENABLE_OAUTH_SHORT, OAUTH_PORT, OAUTH_PORT_SHORT + ); + } + opt_str(OAUTH_PORT) + .map(|port| match port.parse::() { + Ok(value) => { + if value > 0 { + Some(value) + } else { + None + } + } + _ => { + let valid_values = &format!("1 - {}", u16::MAX); + invalid_error_msg(OAUTH_PORT, OAUTH_PORT_SHORT, &port, valid_values, ""); + + exit(1); + } + }) + .unwrap_or(None) + } else { + Some(5588) + }; + + if !enable_discovery && opt_present(ZEROCONF_PORT) { + warn!( + "With the `--{}` / `-{}` flag set `--{}` / `-{}` has no effect.", + DISABLE_DISCOVERY, DISABLE_DISCOVERY_SHORT, ZEROCONF_PORT, ZEROCONF_PORT_SHORT + ); + } + + let zeroconf_port = if enable_discovery { + opt_str(ZEROCONF_PORT) + .map(|port| match port.parse::() { + Ok(value) if value != 0 => value, + _ => { + let valid_values = &format!("1 - {}", u16::MAX); + invalid_error_msg(ZEROCONF_PORT, ZEROCONF_PORT_SHORT, &port, valid_values, ""); + + exit(1); + } + }) + .unwrap_or(0) + } else { + 0 + }; + + // #1046: not all connections are supplied an `autoplay` user attribute to run statelessly. + // This knob allows for a manual override. + let autoplay = match opt_str(AUTOPLAY) { + Some(value) => match value.as_ref() { + "on" => Some(true), + "off" => Some(false), + _ => { + invalid_error_msg( + AUTOPLAY, + AUTOPLAY_SHORT, + &opt_str(AUTOPLAY).unwrap_or_default(), + "on, off", + "", + ); + exit(1); + } + }, + None => SessionConfig::default().autoplay, + }; + + let zeroconf_ip: Vec = if opt_present(ZEROCONF_INTERFACE) { + if let Some(zeroconf_ip) = opt_str(ZEROCONF_INTERFACE) { + zeroconf_ip + .split(',') + .map(|s| { + s.trim().parse::().unwrap_or_else(|_| { + invalid_error_msg( + ZEROCONF_INTERFACE, + ZEROCONF_INTERFACE_SHORT, + s, + "IPv4 and IPv6 addresses", + "", + ); + exit(1); + }) + }) + .collect() + } else { + warn!("Unable to use zeroconf-interface option, default to all interfaces."); + vec![] + } + } else { + vec![] + }; + let connect_config = { let connect_default_config = ConnectConfig::default(); @@ -1260,26 +1413,6 @@ fn get_setup() -> Setup { } }; - // #1046: not all connections are supplied an `autoplay` user attribute to run statelessly. - // This knob allows for a manual override. - let autoplay = match opt_str(AUTOPLAY) { - Some(value) => match value.as_ref() { - "on" => Some(true), - "off" => Some(false), - _ => { - invalid_error_msg( - AUTOPLAY, - AUTOPLAY_SHORT, - &opt_str(AUTOPLAY).unwrap_or_default(), - "on, off", - "", - ); - exit(1); - } - }, - None => SessionConfig::default().autoplay, - }; - let session_config = SessionConfig { device_id: device_id(&connect_config.name), proxy: opt_str(PROXY).or_else(|| std::env::var("http_proxy").ok()).map( @@ -1314,145 +1447,6 @@ fn get_setup() -> Setup { ..SessionConfig::default() }; - let credentials = { - let cached_creds = cache.as_ref().and_then(Cache::credentials); - - let token_port = if opt_present(TOKEN_PORT) { - opt_str(TOKEN_PORT) - .map(|port| match port.parse::() { - Ok(value) => { - if value > 0 { - Some(value) - } else { - None - } - } - _ => { - let valid_values = &format!("1 - {}", u16::MAX); - invalid_error_msg(TOKEN_PORT, TOKEN_PORT_SHORT, &port, valid_values, ""); - - exit(1); - } - }) - .unwrap_or(None) - } else { - Some(5588) - }; - if let Some(mut access_token) = opt_str(TOKEN) { - if access_token.is_empty() { - access_token = match librespot::oauth::get_access_token( - &session_config.client_id, - OAUTH_SCOPES.to_vec(), - token_port, - ) { - Ok(token) => token.access_token, - Err(e) => { - error!("Failed to get Spotify access token: {e}"); - exit(1); - } - }; - } - Some(Credentials::with_access_token(access_token)) - } else if let Some(username) = opt_str(USERNAME) { - if username.is_empty() { - empty_string_error_msg(USERNAME, USERNAME_SHORT); - } - if let Some(password) = opt_str(PASSWORD) { - if password.is_empty() { - empty_string_error_msg(PASSWORD, PASSWORD_SHORT); - } - Some(Credentials::with_password(username, password)) - } else { - match cached_creds { - Some(creds) if Some(&username) == creds.username.as_ref() => Some(creds), - _ => { - let prompt = &format!("Password for {username}: "); - match rpassword::prompt_password(prompt) { - Ok(password) => { - if !password.is_empty() { - Some(Credentials::with_password(username, password)) - } else { - trace!("Password was empty."); - if cached_creds.is_some() { - trace!("Using cached credentials."); - } - cached_creds - } - } - Err(e) => { - warn!("Cannot parse password: {}", e); - if cached_creds.is_some() { - trace!("Using cached credentials."); - } - cached_creds - } - } - } - } - } - } else { - if cached_creds.is_some() { - trace!("Using cached credentials."); - } - cached_creds - } - }; - - let enable_discovery = !opt_present(DISABLE_DISCOVERY); - - if credentials.is_none() && !enable_discovery { - error!("Credentials are required if discovery is disabled."); - exit(1); - } - - if !enable_discovery && opt_present(ZEROCONF_PORT) { - warn!( - "With the `--{}` / `-{}` flag set `--{}` / `-{}` has no effect.", - DISABLE_DISCOVERY, DISABLE_DISCOVERY_SHORT, ZEROCONF_PORT, ZEROCONF_PORT_SHORT - ); - } - - let zeroconf_port = if enable_discovery { - opt_str(ZEROCONF_PORT) - .map(|port| match port.parse::() { - Ok(value) if value != 0 => value, - _ => { - let valid_values = &format!("1 - {}", u16::MAX); - invalid_error_msg(ZEROCONF_PORT, ZEROCONF_PORT_SHORT, &port, valid_values, ""); - - exit(1); - } - }) - .unwrap_or(0) - } else { - 0 - }; - - let zeroconf_ip: Vec = if opt_present(ZEROCONF_INTERFACE) { - if let Some(zeroconf_ip) = opt_str(ZEROCONF_INTERFACE) { - zeroconf_ip - .split(',') - .map(|s| { - s.trim().parse::().unwrap_or_else(|_| { - invalid_error_msg( - ZEROCONF_INTERFACE, - ZEROCONF_INTERFACE_SHORT, - s, - "IPv4 and IPv6 addresses", - "", - ); - exit(1); - }) - }) - .collect() - } else { - warn!("Unable to use zeroconf-interface option, default to all interfaces."); - vec![] - } - } else { - vec![] - }; - let player_config = { let player_default_config = PlayerConfig::default(); @@ -1732,6 +1726,8 @@ fn get_setup() -> Setup { connect_config, mixer_config, credentials, + enable_oauth, + oauth_port, enable_discovery, zeroconf_port, player_event_program, @@ -1807,6 +1803,20 @@ async fn main() { if let Some(credentials) = setup.credentials { last_credentials = Some(credentials); connecting = true; + } else if setup.enable_oauth { + let access_token = match librespot::oauth::get_access_token( + &setup.session_config.client_id, + OAUTH_SCOPES.to_vec(), + setup.oauth_port, + ) { + 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)); + connecting = true; } else if discovery.is_none() { error!( "Discovery is unavailable and no credentials provided. Authentication is not possible." From 5093a88e5f7162d2a453c005c60530abaf9bcc47 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Thu, 5 Sep 2024 00:58:14 +0100 Subject: [PATCH 14/21] oauth: tidy up --- oauth/src/lib.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 27b596a10..24476bec6 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -10,7 +10,7 @@ //! a spawned http server (mimicking Spotify's client), or manually via stdin. The latter //! is appropriate for headless systems. -use log::{debug, error, info, trace}; +use log::{error, info, trace}; use oauth2::reqwest::http_client; use oauth2::{ basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, @@ -66,7 +66,7 @@ pub enum OAuthError { } #[derive(Debug)] -pub struct AccessToken { +pub struct OAuthToken { pub access_token: String, pub refresh_token: String, pub expires_at: Instant, @@ -149,7 +149,7 @@ pub fn get_access_token( client_id: &str, scopes: Vec<&str>, redirect_port: Option, -) -> Result { +) -> Result { // Must use host 127.0.0.1 with Spotify Desktop client ID. let redirect_address = SocketAddr::new( IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), @@ -196,13 +196,12 @@ pub fn get_access_token( } else { get_authcode_stdin() }?; - debug!("Exchange {code:?} for access token"); + 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(); - let client_clone = client.clone(); std::thread::spawn(move || { - let resp = client_clone + let resp = client .exchange_code(code) .set_pkce_verifier(pkce_verifier) .request(http_client); @@ -218,7 +217,7 @@ pub fn get_access_token( Some(s) => s.iter().map(|s| s.to_string()).collect(), _ => scopes.into_iter().map(|s| s.to_string()).collect(), }; - Ok(AccessToken { + Ok(OAuthToken { access_token: token.access_token().secret().to_string(), refresh_token: token.refresh_token().unwrap().secret().to_string(), expires_at: Instant::now() + token.expires_in().unwrap_or(Duration::from_secs(3600)), From 4a24bcd69210ff71dd321d5c7c654282affb5127 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Thu, 5 Sep 2024 23:19:46 +0100 Subject: [PATCH 15/21] core: reconnect session if using token authentication Token authenticated sessions cannot use keymaster. So reconnect using the reusable credentials we just obtained. Can perhaps remove this workaround once keymaster is replaced with login5. --- core/src/session.rs | 58 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 13 deletions(-) diff --git a/core/src/session.rs b/core/src/session.rs index 251c84bd4..3944ce83e 100644 --- a/core/src/session.rs +++ b/core/src/session.rs @@ -13,6 +13,7 @@ use byteorder::{BigEndian, ByteOrder}; use bytes::Bytes; use futures_core::TryStream; use futures_util::{future, ready, StreamExt, TryStreamExt}; +use librespot_protocol::authentication::AuthenticationType; use num_traits::FromPrimitive; use once_cell::sync::OnceCell; use parking_lot::RwLock; @@ -22,13 +23,13 @@ use tokio::{sync::mpsc, time::Instant}; use tokio_stream::wrappers::UnboundedReceiverStream; use crate::{ - apresolve::ApResolver, + apresolve::{ApResolver, SocketAddress}, audio_key::AudioKeyManager, authentication::Credentials, cache::Cache, channel::ChannelManager, config::SessionConfig, - connection::{self, AuthenticationError}, + connection::{self, AuthenticationError, Transport}, http_client::HttpClient, mercury::MercuryManager, packet::PacketType, @@ -141,6 +142,46 @@ impl Session { })) } + async fn connect_inner( + &self, + access_point: SocketAddress, + credentials: Credentials, + ) -> Result<(Credentials, Transport), Error> { + let mut transport = connection::connect( + &access_point.0, + access_point.1, + self.config().proxy.as_ref(), + ) + .await?; + let mut reusable_credentials = connection::authenticate( + &mut transport, + credentials.clone(), + &self.config().device_id, + ) + .await?; + + // Might be able to remove this once keymaster is replaced with login5. + if credentials.auth_type == AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN { + trace!( + "Reconnect using stored credentials as token authed sessions cannot use keymaster." + ); + transport = connection::connect( + &access_point.0, + access_point.1, + self.config().proxy.as_ref(), + ) + .await?; + reusable_credentials = connection::authenticate( + &mut transport, + reusable_credentials.clone(), + &self.config().device_id, + ) + .await?; + } + + Ok((reusable_credentials, transport)) + } + pub async fn connect( &self, credentials: Credentials, @@ -149,17 +190,8 @@ impl Session { let (reusable_credentials, transport) = loop { let ap = self.apresolver().resolve("accesspoint").await?; info!("Connecting to AP \"{}:{}\"", ap.0, ap.1); - let mut transport = - connection::connect(&ap.0, ap.1, self.config().proxy.as_ref()).await?; - - match connection::authenticate( - &mut transport, - credentials.clone(), - &self.config().device_id, - ) - .await - { - Ok(creds) => break (creds, transport), + match self.connect_inner(ap, credentials.clone()).await { + Ok(ct) => break ct, Err(e) => { if let Some(AuthenticationError::LoginFailed(ErrorCode::TryAnotherAP)) = e.error.downcast_ref::() From e2421ac61e2eae80812ce6a0b67e4beb9e3922c0 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Fri, 6 Sep 2024 00:55:29 +0100 Subject: [PATCH 16/21] docs: updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6896180cb..c7bc6bd05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,8 @@ https://github.com/librespot-org/librespot - [core] Cache resolved access points during runtime (breaking) - [core] `FileId` is moved out of `SpotifyId`. For now it will be re-exported. - [core] Report actual platform data on login +- [core] Support `Session` authentication with a Spotify access token +- [core] `Credentials.username` is now an `Option` (breaking) - [main] `autoplay {on|off}` now acts as an override. If unspecified, `librespot` now follows the setting in the Connect client that controls it. (breaking) - [metadata] Most metadata is now retrieved with the `spclient` (breaking) @@ -95,6 +97,7 @@ https://github.com/librespot-org/librespot - [main] Add an event worker thread that runs async to the main thread(s) but sync to itself to prevent potential data races for event consumers - [metadata] All metadata fields in the protobufs are now exposed (breaking) +- [oauth] Standalone module to obtain Spotify access token using OAuth authorization code flow. - [playback] Explicit tracks are skipped if the controlling Connect client has disabled such content. Applications that use librespot as a library without Connect should use the 'filter-explicit-content' user attribute in the session. From a391a87b9bb6b91a5113b1e55cd363b25cf0a4d1 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Fri, 6 Sep 2024 01:06:30 +0100 Subject: [PATCH 17/21] examples: replace password login with token login --- examples/get_token.rs | 25 ++++++------------------- examples/play.rs | 8 ++++---- examples/play_connect.rs | 8 ++++---- examples/playlist_tracks.rs | 8 ++++---- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/examples/get_token.rs b/examples/get_token.rs index 0562b0b20..77b6c8f73 100644 --- a/examples/get_token.rs +++ b/examples/get_token.rs @@ -1,17 +1,21 @@ use std::env; use librespot::core::{authentication::Credentials, config::SessionConfig, session::Session}; -use librespot::protocol::authentication::AuthenticationType; const SCOPES: &str = "streaming,user-read-playback-state,user-modify-playback-state,user-read-currently-playing"; #[tokio::main] async fn main() { + let mut builder = env_logger::Builder::new(); + builder.parse_filters("librespot=trace"); + builder.init(); + let mut session_config = SessionConfig::default(); let args: Vec<_> = env::args().collect(); if args.len() == 3 { + // Only special client IDs have sufficient privileges e.g. Spotify's. session_config.client_id = args[2].clone() } else if args.len() != 2 { eprintln!("Usage: {} ACCESS_TOKEN [CLIENT_ID]", args[0]); @@ -31,23 +35,6 @@ async fn main() { } }; - // This will fail. You can't use keymaster from an access token authed session. - // let token = session.token_provider().get_token(SCOPES).await.unwrap(); - - // Instead, derive stored credentials and auth a new session using those. - let stored_credentials = Credentials { - username: Some(session.username()), - auth_type: AuthenticationType::AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS, - auth_data: session.auth_data(), - }; - let session2 = Session::new(session_config, None); - match session2.connect(stored_credentials, false).await { - Ok(()) => println!("Session username: {:#?}", session2.username()), - Err(e) => { - println!("Error connecting: {}", e); - return; - } - }; - let token = session2.token_provider().get_token(SCOPES).await.unwrap(); + let token = session.token_provider().get_token(SCOPES).await.unwrap(); println!("Got me a token: {token:#?}"); } diff --git a/examples/play.rs b/examples/play.rs index 9e4e29afb..46079632b 100644 --- a/examples/play.rs +++ b/examples/play.rs @@ -22,13 +22,13 @@ async fn main() { let audio_format = AudioFormat::default(); let args: Vec<_> = env::args().collect(); - if args.len() != 4 { - eprintln!("Usage: {} USERNAME PASSWORD TRACK", args[0]); + if args.len() != 3 { + eprintln!("Usage: {} ACCESS_TOKEN TRACK", args[0]); return; } - let credentials = Credentials::with_password(&args[1], &args[2]); + let credentials = Credentials::with_access_token(&args[1]); - let mut track = SpotifyId::from_base62(&args[3]).unwrap(); + let mut track = SpotifyId::from_base62(&args[2]).unwrap(); track.item_type = SpotifyItemType::Track; let backend = audio_backend::find(None).unwrap(); diff --git a/examples/play_connect.rs b/examples/play_connect.rs index a61d3d674..c46464fba 100644 --- a/examples/play_connect.rs +++ b/examples/play_connect.rs @@ -28,16 +28,16 @@ async fn main() { let connect_config = ConnectConfig::default(); let mut args: Vec<_> = env::args().collect(); - let context_uri = if args.len() == 4 { + let context_uri = if args.len() == 3 { args.pop().unwrap() - } else if args.len() == 3 { + } else if args.len() == 2 { String::from("spotify:album:79dL7FLiJFOO0EoehUHQBv") } else { - eprintln!("Usage: {} USERNAME PASSWORD (ALBUM URI)", args[0]); + eprintln!("Usage: {} ACCESS_TOKEN (ALBUM URI)", args[0]); return; }; - let credentials = Credentials::with_password(&args[1], &args[2]); + let credentials = Credentials::with_access_token(&args[1]); let backend = audio_backend::find(None).unwrap(); println!("Connecting..."); diff --git a/examples/playlist_tracks.rs b/examples/playlist_tracks.rs index ddf456ac6..18fc2e371 100644 --- a/examples/playlist_tracks.rs +++ b/examples/playlist_tracks.rs @@ -13,13 +13,13 @@ async fn main() { let session_config = SessionConfig::default(); let args: Vec<_> = env::args().collect(); - if args.len() != 4 { - eprintln!("Usage: {} USERNAME PASSWORD PLAYLIST", args[0]); + if args.len() != 3 { + eprintln!("Usage: {} ACCESS_TOKEN PLAYLIST", args[0]); return; } - let credentials = Credentials::with_password(&args[1], &args[2]); + let credentials = Credentials::with_access_token(&args[1]); - let plist_uri = SpotifyId::from_uri(&args[3]).unwrap_or_else(|_| { + let plist_uri = SpotifyId::from_uri(&args[2]).unwrap_or_else(|_| { eprintln!( "PLAYLIST should be a playlist URI such as: \ \"spotify:playlist:37i9dQZF1DXec50AjHrNTq\"" From f14208e42877b286f7d6bf4c25d18d001f5ff0e5 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Sun, 8 Sep 2024 22:44:42 +0100 Subject: [PATCH 18/21] oauth: resolve review improvements --- oauth/src/lib.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs index 24476bec6..371e9aa6e 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -58,7 +58,7 @@ pub enum OAuthError { #[error("Invalid Redirect URI {uri} ({e})")] InvalidRedirectUri { uri: String, e: url::ParseError }, - #[error("Failed to recieve code")] + #[error("Failed to receive code")] Recv, #[error("Failed to exchange code for access token ({e})")] @@ -217,10 +217,17 @@ pub fn get_access_token( Some(s) => s.iter().map(|s| s.to_string()).collect(), _ => scopes.into_iter().map(|s| s.to_string()).collect(), }; + let refresh_token = match token.refresh_token() { + Some(t) => t.secret().to_string(), + _ => "".to_string(), // Spotify always provides a refresh token. + }; Ok(OAuthToken { access_token: token.access_token().secret().to_string(), - refresh_token: token.refresh_token().unwrap().secret().to_string(), - expires_at: Instant::now() + token.expires_in().unwrap_or(Duration::from_secs(3600)), + refresh_token, + expires_at: Instant::now() + + token + .expires_in() + .unwrap_or_else(|| Duration::from_secs(3600)), token_type: format!("{:?}", token.token_type()).to_string(), // Urgh!? scopes: token_scopes, }) From 68eebc853f1dbc4d4fc06b0d14da05f03d77cda6 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Sun, 8 Sep 2024 23:55:41 +0100 Subject: [PATCH 19/21] oauth: Remove examples's core dependency --- oauth/Cargo.toml | 4 ---- oauth/examples/oauth.rs | 10 ++++------ 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml index a8a20a79b..646f08799 100644 --- a/oauth/Cargo.toml +++ b/oauth/Cargo.toml @@ -14,9 +14,5 @@ oauth2 = "4.4" thiserror = "1.0" url = "2.2" -[dependencies.librespot-core] -path = "../core" -version = "0.5.0-dev" - [dev-dependencies] env_logger = { version = "0.11.2", default-features = false, features = ["color", "humantime", "auto-color"] } \ No newline at end of file diff --git a/oauth/examples/oauth.rs b/oauth/examples/oauth.rs index 86e1453fc..fdfe09e06 100644 --- a/oauth/examples/oauth.rs +++ b/oauth/examples/oauth.rs @@ -1,16 +1,14 @@ -use librespot_core::SessionConfig; use librespot_oauth::get_access_token; +// You can use any client ID here but it must be configured to allow redirect URI http://127.0.0.1 +const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; + fn main() { let mut builder = env_logger::Builder::new(); builder.parse_filters("librespot=trace"); builder.init(); - match get_access_token( - &SessionConfig::default().client_id, - vec!["streaming"], - Some(1337), - ) { + match get_access_token(SPOTIFY_CLIENT_ID, vec!["streaming"], Some(1337)) { Ok(token) => println!("Success: {token:#?}"), Err(e) => println!("Failed: {e}"), }; From fd1f618300556b0375111822f901a4eeb28954ec Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Sun, 8 Sep 2024 23:57:05 +0100 Subject: [PATCH 20/21] core: From for Error --- core/Cargo.toml | 4 ++++ core/src/error.rs | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/core/Cargo.toml b/core/Cargo.toml index ea9a906ef..3bc06712f 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -9,6 +9,10 @@ license = "MIT" repository = "https://github.com/librespot-org/librespot" edition = "2021" +[dependencies.librespot-oauth] +path = "../oauth" +version = "0.5.0-dev" + [dependencies.librespot-protocol] path = "../protocol" version = "0.5.0-dev" diff --git a/core/src/error.rs b/core/src/error.rs index 13491a399..b18ce91a5 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -19,6 +19,8 @@ use tokio::sync::{ }; use url::ParseError; +use librespot_oauth::OAuthError; + #[cfg(feature = "with-dns-sd")] use dns_sd::DNSError; @@ -287,6 +289,25 @@ impl fmt::Display for Error { } } +impl From for Error { + fn from(err: OAuthError) -> Self { + use OAuthError::*; + match err { + AuthCodeBadUri { .. } + | AuthCodeNotFound { .. } + | AuthCodeListenerRead + | AuthCodeListenerParse => Error::unavailable(err), + AuthCodeStdinRead + | AuthCodeListenerBind { .. } + | AuthCodeListenerTerminated + | AuthCodeListenerWrite + | Recv + | ExchangeCode { .. } => Error::internal(err), + _ => Error::failed_precondition(err), + } + } +} + impl From for Error { fn from(err: DecodeError) -> Self { Self::new(ErrorKind::FailedPrecondition, err) From 4dc4157e5991e45fd15991397fa8a45dff8feba5 Mon Sep 17 00:00:00 2001 From: Nick Steel Date: Wed, 11 Sep 2024 01:00:56 +0100 Subject: [PATCH 21/21] oauth: derive socket addr from redirect URI param. --- oauth/examples/oauth.rs | 21 ++++++++- oauth/src/lib.rs | 95 ++++++++++++++++++++++++++++++++--------- src/main.rs | 6 ++- 3 files changed, 98 insertions(+), 24 deletions(-) diff --git a/oauth/examples/oauth.rs b/oauth/examples/oauth.rs index fdfe09e06..76ff088e3 100644 --- a/oauth/examples/oauth.rs +++ b/oauth/examples/oauth.rs @@ -1,14 +1,31 @@ +use std::env; + use librespot_oauth::get_access_token; -// You can use any client ID here but it must be configured to allow redirect URI http://127.0.0.1 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(); - match get_access_token(SPOTIFY_CLIENT_ID, vec!["streaming"], Some(1337)) { + 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/src/lib.rs b/oauth/src/lib.rs index 371e9aa6e..591e65594 100644 --- a/oauth/src/lib.rs +++ b/oauth/src/lib.rs @@ -20,7 +20,7 @@ use std::io; use std::time::{Duration, Instant}; use std::{ io::{BufRead, BufReader, Write}, - net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}, + net::{SocketAddr, TcpListener}, sync::mpsc, }; use thiserror::Error; @@ -103,7 +103,7 @@ fn get_authcode_stdin() -> Result { get_code(buffer.trim()) } -/// Spawn HTTP server on provided socket to accept OAuth callback and return auth code. +/// Spawn HTTP server at provided socket address to accept OAuth callback and return auth code. fn get_authcode_listener(socket_address: SocketAddr) -> Result { let listener = TcpListener::bind(socket_address).map_err(|e| OAuthError::AuthCodeListenerBind { @@ -143,36 +143,48 @@ fn get_authcode_listener(socket_address: SocketAddr) -> Result Option { + let url = match Url::parse(redirect_uri) { + Ok(u) if u.scheme() == "http" && u.port().is_some() => u, + _ => return None, + }; + let socket_addr = match url.socket_addrs(|| None) { + Ok(mut addrs) => addrs.pop(), + _ => None, + }; + if let Some(s) = socket_addr { + if s.ip().is_loopback() { + return socket_addr; + } + } + None +} + /// 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, scopes: Vec<&str>, - redirect_port: Option, ) -> Result { - // Must use host 127.0.0.1 with Spotify Desktop client ID. - let redirect_address = SocketAddr::new( - IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), - redirect_port.unwrap_or_default(), - ); - let redirect_uri = format!("http://{redirect_address}/login"); - 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(RedirectUrl::new(redirect_uri.clone()).map_err(|e| { - OAuthError::InvalidRedirectUri { - uri: redirect_uri, - e, - } - })?); + .set_redirect_uri(redirect_url); let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); @@ -191,10 +203,9 @@ pub fn get_access_token( println!("Browse to: {}", auth_url); - let code = if redirect_port.is_some() { - get_authcode_listener(redirect_address) - } else { - get_authcode_stdin() + let code = match get_socket_address(redirect_uri) { + Some(addr) => get_authcode_listener(addr), + _ => get_authcode_stdin(), }?; trace!("Exchange {code:?} for access token"); @@ -232,3 +243,45 @@ pub fn get_access_token( scopes: token_scopes, }) } + +#[cfg(test)] +mod test { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + + use super::*; + + #[test] + fn get_socket_address_none() { + // No port + assert_eq!(get_socket_address("http://127.0.0.1/foo"), None); + assert_eq!(get_socket_address("http://127.0.0.1:/foo"), None); + assert_eq!(get_socket_address("http://[::1]/foo"), None); + // Not localhost + assert_eq!(get_socket_address("http://56.0.0.1:1234/foo"), None); + assert_eq!( + get_socket_address("http://[3ffe:2a00:100:7031::1]:1234/foo"), + None + ); + // Not http + assert_eq!(get_socket_address("https://127.0.0.1/foo"), None); + } + + #[test] + fn get_socket_address_localhost() { + let localhost_v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 1234); + let localhost_v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)), 8888); + + assert_eq!( + get_socket_address("http://127.0.0.1:1234/foo"), + Some(localhost_v4) + ); + assert_eq!( + get_socket_address("http://[0:0:0:0:0:0:0:1]:8888/foo"), + Some(localhost_v6) + ); + assert_eq!( + get_socket_address("http://[::1]:8888/foo"), + Some(localhost_v6) + ); + } +} diff --git a/src/main.rs b/src/main.rs index 7df99a093..948d700c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1804,10 +1804,14 @@ async fn main() { last_credentials = Some(credentials); connecting = true; } else if setup.enable_oauth { + let port_str = match setup.oauth_port { + Some(port) => format!(":{port}"), + _ => String::new(), + }; let access_token = match librespot::oauth::get_access_token( &setup.session_config.client_id, + &format!("http://127.0.0.1{port_str}/login"), OAUTH_SCOPES.to_vec(), - setup.oauth_port, ) { Ok(token) => token.access_token, Err(e) => {