Skip to content

Commit

Permalink
auth: implement oauth support
Browse files Browse the repository at this point in the history
  • Loading branch information
eladyn committed Dec 30, 2024
1 parent f93a6f3 commit 836fbfa
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 74 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 14 additions & 14 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -25,6 +24,7 @@ mod dbus_mpris;
mod error;
mod main_loop;
mod no_mixer;
mod oauth;
mod process;
mod setup;
mod utils;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
35 changes: 21 additions & 14 deletions src/main_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,28 +29,35 @@ pub struct SpotifydState {
}

pub(crate) enum CredentialsProvider {
Discovery(Peekable<Discovery>),
SpotifyCredentials(Credentials),
}

impl From<Discovery> for CredentialsProvider {
fn from(stream: Discovery) -> Self {
CredentialsProvider::Discovery(stream.peekable())
}
Discovery {
stream: Peekable<Discovery>,
last_credentials: Option<Credentials>,
},
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
Expand Down Expand Up @@ -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() => {
Expand Down Expand Up @@ -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() {
Expand Down
75 changes: 75 additions & 0 deletions src/oauth.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
112 changes: 66 additions & 46 deletions src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down

0 comments on commit 836fbfa

Please sign in to comment.