From 836fbfa107c5a20f1dd7db3b2327aed1f0f1f2f4 Mon Sep 17 00:00:00 2001 From: eladyn Date: Mon, 30 Dec 2024 02:06:19 +0100 Subject: [PATCH] auth: implement oauth support --- Cargo.lock | 1 + Cargo.toml | 1 + src/main.rs | 28 ++++++------ src/main_loop.rs | 35 +++++++++------ src/oauth.rs | 75 +++++++++++++++++++++++++++++++ src/setup.rs | 112 ++++++++++++++++++++++++++++------------------- 6 files changed, 178 insertions(+), 74 deletions(-) create mode 100644 src/oauth.rs diff --git a/Cargo.lock b/Cargo.lock index 0fc9fc82..7310f5d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3232,6 +3232,7 @@ dependencies = [ "librespot-core", "librespot-discovery", "librespot-metadata", + "librespot-oauth", "librespot-playback", "librespot-protocol", "log", diff --git a/Cargo.toml b/Cargo.toml index ffd56cee..360bb895 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ librespot-discovery = "0.6" librespot-connect = "0.6" librespot-metadata = "0.6" librespot-protocol = "0.6" +librespot-oauth = "0.6" toml = "0.8.19" color-eyre = "0.6" directories = "5.0.1" diff --git a/src/main.rs b/src/main.rs index 941f2e6b..f041ab91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,15 +2,14 @@ use crate::config::CliConfig; use clap::Parser; #[cfg(unix)] use color_eyre::eyre::eyre; -use color_eyre::{ - eyre::{self, Context}, - Help, SectionExt, -}; +use color_eyre::eyre::{self, Context}; +use config::ExecutionMode; #[cfg(unix)] use daemonize::Daemonize; #[cfg(unix)] use log::error; use log::{info, trace, LevelFilter}; +use oauth::run_oauth; #[cfg(target_os = "openbsd")] use pledge::pledge; #[cfg(windows)] @@ -25,6 +24,7 @@ mod dbus_mpris; mod error; mod main_loop; mod no_mixer; +mod oauth; mod process; mod setup; mod utils; @@ -63,7 +63,7 @@ fn setup_logger(log_target: LogTarget, verbose: u8) -> eyre::Result<()> { facility: syslog::Facility::LOG_DAEMON, hostname: None, process: "spotifyd".to_owned(), - pid: 0, + pid: std::process::id(), }; logger.chain( syslog::unix(log_format) @@ -106,8 +106,15 @@ fn main() -> eyre::Result<()> { color_eyre::install().wrap_err("Couldn't initialize error reporting")?; - let mut cli_config = CliConfig::parse(); + let cli_config = CliConfig::parse(); + match cli_config.mode { + None => run_daemon(cli_config), + Some(ExecutionMode::Authenticate { oauth_port }) => run_oauth(cli_config, oauth_port), + } +} + +fn run_daemon(mut cli_config: CliConfig) -> eyre::Result<()> { let is_daemon = !cli_config.no_daemon; let log_target = if is_daemon { @@ -138,14 +145,7 @@ fn main() -> eyre::Result<()> { cli_config .load_config_file_values() - .wrap_err("could not load the config file") - .with_section(|| { - concat!( - "the config format should be valid TOML\n", - "we recently changed the config format, see https://github.com/Spotifyd/spotifyd/issues/765" - ) - .header("note:") - })?; + .wrap_err("could not load the config file")?; trace!("{:?}", &cli_config); // Returns the old SpotifydConfig struct used within the rest of the daemon. diff --git a/src/main_loop.rs b/src/main_loop.rs index b629831c..b68c9706 100644 --- a/src/main_loop.rs +++ b/src/main_loop.rs @@ -29,28 +29,35 @@ pub struct SpotifydState { } pub(crate) enum CredentialsProvider { - Discovery(Peekable), - SpotifyCredentials(Credentials), -} - -impl From for CredentialsProvider { - fn from(stream: Discovery) -> Self { - CredentialsProvider::Discovery(stream.peekable()) - } + Discovery { + stream: Peekable, + last_credentials: Option, + }, + CredentialsOnly(Credentials), } impl CredentialsProvider { async fn get_credentials(&mut self) -> Credentials { match self { - CredentialsProvider::Discovery(stream) => stream.next().await.unwrap(), - CredentialsProvider::SpotifyCredentials(creds) => creds.clone(), + CredentialsProvider::Discovery { + stream, + last_credentials, + } => { + let new_creds = match last_credentials.take() { + Some(creds) => stream.next().now_or_never().flatten().unwrap_or(creds), + None => stream.next().await.unwrap(), + }; + *last_credentials = Some(new_creds.clone()); + new_creds + } + CredentialsProvider::CredentialsOnly(creds) => creds.clone(), } } // wait for an incoming connection if the underlying provider is a discovery stream async fn incoming_connection(&mut self) { match self { - CredentialsProvider::Discovery(stream) => { + CredentialsProvider::Discovery { stream, .. } => { let peeked = Pin::new(stream).peek().await; if peeked.is_none() { future::pending().await @@ -119,9 +126,6 @@ impl MainLoop { 'mainloop: loop { let (spirc, spirc_task) = tokio::select!( _ = &mut ctrl_c => { - if let CredentialsProvider::Discovery(stream) = self.credentials_provider { - let _ = stream.into_inner().shutdown().await; - } break 'mainloop; } spirc = self.get_connection() => { @@ -215,6 +219,9 @@ impl MainLoop { } } } + if let CredentialsProvider::Discovery { stream, .. } = self.credentials_provider { + let _ = stream.into_inner().shutdown().await; + } #[cfg(feature = "dbus_mpris")] if let Either::Left(dbus_server) = Either::as_pin_mut(dbus_server.as_mut()) { if dbus_server.shutdown() { diff --git a/src/oauth.rs b/src/oauth.rs new file mode 100644 index 00000000..1effeee7 --- /dev/null +++ b/src/oauth.rs @@ -0,0 +1,75 @@ +use color_eyre::{ + eyre::{self, Context as _}, + Section as _, +}; +use librespot_core::SessionConfig; +use librespot_core::{authentication::Credentials, Session}; +use log::info; +use tokio::runtime::Runtime; + +use crate::{config::CliConfig, setup_logger, LogTarget}; + +pub(crate) fn run_oauth(mut cli_config: CliConfig, oauth_port: u16) -> eyre::Result<()> { + setup_logger(LogTarget::Terminal, cli_config.verbose)?; + + cli_config + .load_config_file_values() + .wrap_err("failed to read config file")?; + + let cache = cli_config + .shared_config + .get_cache(true) + .with_note(|| "The result of the authentication needs to be cached to be usable later.")?; + + const OAUTH_SCOPES: &[&str] = &[ + "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 session_config = SessionConfig { + proxy: cli_config.shared_config.proxy_url(), + ..Default::default() + }; + + let token = librespot_oauth::get_access_token( + &session_config.client_id, + &format!("http://127.0.0.1:{oauth_port}/login"), + OAUTH_SCOPES.to_vec(), + ) + .wrap_err("token retrieval failed")?; + + let creds = Credentials::with_access_token(token.access_token); + + Runtime::new().unwrap().block_on(async move { + let session = Session::new(session_config, Some(cache)); + session.connect(creds, true).await + })?; + + info!("\nLogin successful! You are now ready to run spotifyd."); + + Ok(()) +} diff --git a/src/setup.rs b/src/setup.rs index 5fe5b2aa..ca0abc30 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -4,10 +4,8 @@ use crate::{ config, main_loop::{self, CredentialsProvider}, }; -use color_eyre::{ - eyre::{eyre, Context}, - Section, -}; +use color_eyre::{eyre::eyre, Section}; +use futures::StreamExt as _; use librespot_core::Session; use librespot_playback::{ audio_backend::{self}, @@ -53,7 +51,6 @@ pub(crate) fn initial_state( } }; - let cache = config.cache; let player_config = config.player_config; let session_config = config.session_config; let backend = config.backend.clone(); @@ -62,52 +59,75 @@ pub(crate) fn initial_state( let zeroconf_port = config.zeroconf_port.unwrap_or(0); - let credentials_provider = - if let Some(credentials) = cache.as_ref().and_then(|c| c.credentials()) { - CredentialsProvider::SpotifyCredentials(credentials) - } else if config.discovery { - info!("no usable credentials found, enabling discovery"); - debug!("Using device id '{}'", session_config.device_id); - const RETRY_MAX: u8 = 4; - let mut retry_counter = 0; - let mut backoff = Duration::from_secs(5); - let discovery_stream = loop { - match librespot_discovery::Discovery::builder( - session_config.device_id.clone(), - session_config.client_id.clone(), - ) - .name(config.device_name.clone()) - .device_type(config.device_type) - .port(zeroconf_port) - .launch() - { - Ok(discovery_stream) => break discovery_stream, - Err(err) => { - error!("failed to enable discovery: {err}"); - if retry_counter >= RETRY_MAX { - return Err(err).with_context(|| { - "failed to enable discovery (and no credentials provided)" - }); - } - info!("retrying discovery in {} seconds", backoff.as_secs()); - thread::sleep(backoff); - retry_counter += 1; - backoff *= 2; - info!("trying to enable discovery (retry {retry_counter}/{RETRY_MAX})"); + let creds = if let Some(creds) = config.oauth_cache.as_ref().and_then(|c| c.credentials()) { + info!( + "Login via OAuth as user {}.", + creds.username.as_deref().unwrap_or("unknown") + ); + Some(creds) + } else if let Some(creds) = config.cache.as_ref().and_then(|c| c.credentials()) { + info!( + "Restoring previous login as user {}.", + creds.username.as_deref().unwrap_or("unknown") + ); + Some(creds) + } else { + None + }; + + let discovery = if config.discovery { + info!("Starting zeroconf server to advertise on local network."); + debug!("Using device id '{}'", session_config.device_id); + const RETRY_MAX: u8 = 4; + let mut retry_counter = 0; + let mut backoff = Duration::from_secs(5); + loop { + match librespot_discovery::Discovery::builder( + session_config.device_id.clone(), + session_config.client_id.clone(), + ) + .name(config.device_name.clone()) + .device_type(config.device_type) + .port(zeroconf_port) + .launch() + { + Ok(discovery_stream) => break Some(discovery_stream), + Err(err) => { + error!("failed to enable discovery: {err}"); + if retry_counter >= RETRY_MAX { + error!("maximum amount of retries exceeded"); + break None; } + info!("retrying discovery in {} seconds", backoff.as_secs()); + thread::sleep(backoff); + retry_counter += 1; + backoff *= 2; + info!("trying to enable discovery (retry {retry_counter}/{RETRY_MAX})"); } - }; - discovery_stream.into() - } else { - return Err(eyre!( - "no cached credentials available and discovery disabled" - )) - .with_suggestion(|| "consider enabling discovery or authenticating via OAuth"); - }; + } + } + } else { + None + }; + + let credentials_provider = match (discovery, creds) { + (Some(stream), creds) => CredentialsProvider::Discovery { + stream: stream.peekable(), + last_credentials: creds, + }, + (None, Some(creds)) => CredentialsProvider::CredentialsOnly(creds), + (None, None) => { + return Err( + eyre!("Discovery unavailable and no credentials found.").with_suggestion(|| { + "Try enabling discovery or logging in first with `spotifyd authenticate`." + }), + ); + } + }; let backend = audio_backend::find(backend).expect("available backends should match ours"); - let session = Session::new(session_config, cache); + let session = Session::new(session_config, config.cache); let player = { let audio_device = config.audio_device; let audio_format = config.audio_format;