diff --git a/CHANGELOG.md b/CHANGELOG.md index f320c104..681762d3 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. diff --git a/Cargo.toml b/Cargo.toml index 00d94577..fe2e38cf 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/core/Cargo.toml b/core/Cargo.toml index 680e6b90..01919ba7 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/authentication.rs b/core/src/authentication.rs index 8122d659..230661ef 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")] @@ -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 { - username: username.into(), + pub fn with_password(username: impl Into, password: impl Into) -> Self { + Self { + username: Some(username.into()), auth_type: AuthenticationType::AUTHENTICATION_USER_PASS, auth_data: password.into().into_bytes(), } } + pub fn with_access_token(token: impl Into) -> Self { + Self { + username: None, + 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,8 +144,8 @@ impl Credentials { read_u8(&mut cursor)?; let auth_data = read_bytes(&mut cursor)?; - Ok(Credentials { - username, + Ok(Self { + username: Some(username), auth_type, auth_data, }) diff --git a/core/src/connection/mod.rs b/core/src/connection/mod.rs index 4bac6e3e..b2e0356b 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() @@ -133,6 +135,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() @@ -144,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/error.rs b/core/src/error.rs index 13491a39..b18ce91a 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) diff --git a/core/src/session.rs b/core/src/session.rs old mode 100755 new mode 100644 index b8c55d20..3944ce83 --- 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, @@ -77,6 +78,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, @@ -140,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, @@ -148,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::() @@ -172,8 +205,13 @@ 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); + self.set_auth_data(&reusable_credentials.auth_data); if let Some(cache) = self.cache() { if store_credentials { let cred_changed = cache @@ -471,6 +509,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() } diff --git a/examples/get_token.rs b/examples/get_token.rs index 0473e122..77b6c8f7 100644 --- a/examples/get_token.rs +++ b/examples/get_token.rs @@ -7,23 +7,34 @@ const SCOPES: &str = #[tokio::main] async fn main() { - let session_config = SessionConfig::default(); + 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 { - eprintln!("Usage: {} USERNAME PASSWORD", args[0]); + 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]); return; } + let access_token = &args[1]; - println!("Connecting..."); - let credentials = Credentials::with_password(&args[1], &args[2]); - let session = Session::new(session_config, None); - + // Now create a new session with that token. + let session = Session::new(session_config.clone(), None); + let credentials = Credentials::with_access_token(access_token); + println!("Connecting with token.."); match session.connect(credentials, false).await { - Ok(()) => println!( - "Token: {:#?}", - session.token_provider().get_token(SCOPES).await.unwrap() - ), - Err(e) => println!("Error connecting: {}", e), - } + Ok(()) => println!("Session username: {:#?}", session.username()), + Err(e) => { + println!("Error connecting: {e}"); + return; + } + }; + + 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 9e4e29af..46079632 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 a61d3d67..c46464fb 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 ddf456ac..18fc2e37 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\"" diff --git a/oauth/Cargo.toml b/oauth/Cargo.toml new file mode 100644 index 00000000..646f0879 --- /dev/null +++ b/oauth/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "librespot-oauth" +version = "0.5.0-dev" +rust-version = "1.73" +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" + +[dependencies] +log = "0.4" +oauth2 = "4.4" +thiserror = "1.0" +url = "2.2" + +[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 00000000..76ff088e --- /dev/null +++ b/oauth/examples/oauth.rs @@ -0,0 +1,32 @@ +use std::env; + +use librespot_oauth::get_access_token; + +const SPOTIFY_CLIENT_ID: &str = "65b708073fc0480ea92a077233ca87bd"; +const SPOTIFY_REDIRECT_URI: &str = "http://127.0.0.1:8898/login"; + +fn main() { + let mut builder = env_logger::Builder::new(); + builder.parse_filters("librespot=trace"); + builder.init(); + + let args: Vec<_> = env::args().collect(); + let (client_id, redirect_uri, scopes) = if args.len() == 4 { + // You can use your own client ID, along with it's associated redirect URI. + ( + args[1].as_str(), + args[2].as_str(), + args[3].split(',').collect::>(), + ) + } else if args.len() == 1 { + (SPOTIFY_CLIENT_ID, SPOTIFY_REDIRECT_URI, vec!["streaming"]) + } else { + eprintln!("Usage: {} [CLIENT_ID REDIRECT_URI SCOPES]", args[0]); + return; + }; + + match get_access_token(client_id, redirect_uri, scopes) { + Ok(token) => println!("Success: {token:#?}"), + Err(e) => println!("Failed: {e}"), + }; +} diff --git a/oauth/src/lib.rs b/oauth/src/lib.rs new file mode 100644 index 00000000..591e6559 --- /dev/null +++ b/oauth/src/lib.rs @@ -0,0 +1,287 @@ +//! 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::{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::time::{Duration, Instant}; +use std::{ + io::{BufRead, BufReader, Write}, + net::{SocketAddr, TcpListener}, + sync::mpsc, +}; +use thiserror::Error; +use url::Url; + +#[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 receive code")] + Recv, + + #[error("Failed to exchange code for access token ({e})")] + ExchangeCode { e: String }, +} + +#[derive(Debug)] +pub struct OAuthToken { + 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 { + uri: redirect_url.to_string(), + e, + })?; + let code = url + .query_pairs() + .find(|(key, _)| key == "code") + .map(|(_, code)| AuthorizationCode::new(code.into_owned())) + .ok_or(OAuthError::AuthCodeNotFound { + uri: redirect_url.to_string(), + })?; + + Ok(code) +} + +/// 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(); + stdin + .read_line(&mut buffer) + .map_err(|_| OAuthError::AuthCodeStdinRead)?; + + get_code(buffer.trim()) +} + +/// 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 { + addr: socket_address, + e, + })?; + info!("OAuth server listening on {:?}", socket_address); + + // The server will terminate itself after collecting the first code. + 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) + .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 :)"; + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", + message.len(), + message + ); + stream + .write_all(response.as_bytes()) + .map_err(|_| OAuthError::AuthCodeListenerWrite)?; + + code +} + +// 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 { + 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>, +) -> Result { + let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let token_url = TokenUrl::new("https://accounts.spotify.com/api/token".to_string()) + .map_err(|_| OAuthError::InvalidSpotifyUri)?; + let redirect_url = + RedirectUrl::new(redirect_uri.to_string()).map_err(|e| OAuthError::InvalidRedirectUri { + uri: redirect_uri.to_string(), + e, + })?; + let client = BasicClient::new( + ClientId::new(client_id.to_string()), + None, + auth_url, + Some(token_url), + ) + .set_redirect_uri(redirect_url); + + let (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 = scopes + .clone() + .into_iter() + .map(|s| Scope::new(s.into())) + .collect(); + let (auth_url, _) = client + .authorize_url(CsrfToken::new_random) + .add_scopes(request_scopes) + .set_pkce_challenge(pkce_challenge) + .url(); + + println!("Browse to: {}", auth_url); + + let code = match get_socket_address(redirect_uri) { + Some(addr) => get_authcode_listener(addr), + _ => get_authcode_stdin(), + }?; + trace!("Exchange {code:?} for access token"); + + // Do this sync in another thread because I am too stupid to make the async version work. + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + let resp = client + .exchange_code(code) + .set_pkce_verifier(pkce_verifier) + .request(http_client); + if let Err(e) = tx.send(resp) { + error!("OAuth channel send error: {e}"); + } + }); + let token_response = rx.recv().map_err(|_| OAuthError::Recv)?; + let token = token_response.map_err(|e| OAuthError::ExchangeCode { e: e.to_string() })?; + trace!("Obtained new access token: {token:?}"); + + let token_scopes: Vec = match token.scopes() { + 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, + 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, + }) +} + +#[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/publish.sh b/publish.sh index c39f1c96..c9982c97 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() { diff --git a/src/lib.rs b/src/lib.rs index 75211282..f6a17654 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 a6e54d44..2735ddf7 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, @@ -179,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, @@ -195,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"; @@ -210,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"; @@ -226,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"; @@ -260,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"; @@ -381,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, @@ -457,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, @@ -670,7 +726,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_ACCESS_TOKEN" + ) { trace!("\t\t{k}=\"XXXXXXXX\""); } else if v.is_empty() { trace!("\t\t{k}="); @@ -702,7 +761,15 @@ 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 + | ACCESS_TOKEN + | ACCESS_TOKEN_SHORT + ) { // Don't log creds. trace!("\t\t{opt} \"XXXXXXXX\""); } else { @@ -1081,44 +1148,32 @@ 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(username) = opt_str(USERNAME) { + 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 let Some(password) = opt_str(PASSWORD) { - if password.is_empty() { - empty_string_error_msg(PASSWORD, PASSWORD_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) } - Some(Credentials::with_password(username, password)) - } else { - match cached_creds { - Some(creds) if username == creds.username => 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 - } - } - } + _ => { + trace!("No cached credentials for specified username."); + None } } } else { @@ -1131,11 +1186,39 @@ fn get_setup() -> Setup { let enable_discovery = !opt_present(DISABLE_DISCOVERY); - if credentials.is_none() && !enable_discovery { - error!("Credentials are required if discovery is disabled."); + 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.", @@ -1643,6 +1726,8 @@ fn get_setup() -> Setup { connect_config, mixer_config, credentials, + enable_oauth, + oauth_port, enable_discovery, zeroconf_port, player_event_program, @@ -1718,6 +1803,24 @@ async fn main() { if let Some(credentials) = setup.credentials { 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(), + ) { + 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."