Credentials with access token (oauth) (#1309)

* core: Create credentials from access token via OAuth2

* core: Credentials.username is optional: not required for token auth.

* core: store auth data within session. We might need this later if need to re-auth and original creds are no longer valid/available.

* bin: New --token arg for using Spotify access token. Specify 0 to manually enter the auth code (headless).

* bin: Added --enable-oauth / -j option. Using --password / -p option will error and exit.

* 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.

* examples: replace password login with token login
This commit is contained in:
Nick Steel 2024-09-13 06:35:55 +01:00 committed by GitHub
parent f6473319f6
commit 4f9151c642
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 629 additions and 88 deletions

View file

@ -55,6 +55,8 @@ https://github.com/librespot-org/librespot
- [core] Cache resolved access points during runtime (breaking) - [core] Cache resolved access points during runtime (breaking)
- [core] `FileId` is moved out of `SpotifyId`. For now it will be re-exported. - [core] `FileId` is moved out of `SpotifyId`. For now it will be re-exported.
- [core] Report actual platform data on login - [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` - [main] `autoplay {on|off}` now acts as an override. If unspecified, `librespot`
now follows the setting in the Connect client that controls it. (breaking) now follows the setting in the Connect client that controls it. (breaking)
- [metadata] Most metadata is now retrieved with the `spclient` (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 - [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 sync to itself to prevent potential data races for event consumers
- [metadata] All metadata fields in the protobufs are now exposed (breaking) - [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 - [playback] Explicit tracks are skipped if the controlling Connect client has
disabled such content. Applications that use librespot as a library without disabled such content. Applications that use librespot as a library without
Connect should use the 'filter-explicit-content' user attribute in the session. Connect should use the 'filter-explicit-content' user attribute in the session.

View file

@ -49,6 +49,10 @@ version = "0.5.0-dev"
path = "protocol" path = "protocol"
version = "0.5.0-dev" version = "0.5.0-dev"
[dependencies.librespot-oauth]
path = "oauth"
version = "0.5.0-dev"
[dependencies] [dependencies]
data-encoding = "2.5" data-encoding = "2.5"
env_logger = { version = "0.11.2", default-features = false, features = ["color", "humantime", "auto-color"] } env_logger = { version = "0.11.2", default-features = false, features = ["color", "humantime", "auto-color"] }

View file

@ -9,6 +9,10 @@ license = "MIT"
repository = "https://github.com/librespot-org/librespot" repository = "https://github.com/librespot-org/librespot"
edition = "2021" edition = "2021"
[dependencies.librespot-oauth]
path = "../oauth"
version = "0.5.0-dev"
[dependencies.librespot-protocol] [dependencies.librespot-protocol]
path = "../protocol" path = "../protocol"
version = "0.5.0-dev" version = "0.5.0-dev"

View file

@ -29,7 +29,7 @@ impl From<AuthenticationError> for Error {
/// The credentials are used to log into the Spotify API. /// The credentials are used to log into the Spotify API.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct Credentials { pub struct Credentials {
pub username: String, pub username: Option<String>,
#[serde(serialize_with = "serialize_protobuf_enum")] #[serde(serialize_with = "serialize_protobuf_enum")]
#[serde(deserialize_with = "deserialize_protobuf_enum")] #[serde(deserialize_with = "deserialize_protobuf_enum")]
@ -50,19 +50,27 @@ impl Credentials {
/// ///
/// let creds = Credentials::with_password("my account", "my password"); /// let creds = Credentials::with_password("my account", "my password");
/// ``` /// ```
pub fn with_password(username: impl Into<String>, password: impl Into<String>) -> Credentials { pub fn with_password(username: impl Into<String>, password: impl Into<String>) -> Self {
Credentials { Self {
username: username.into(), username: Some(username.into()),
auth_type: AuthenticationType::AUTHENTICATION_USER_PASS, auth_type: AuthenticationType::AUTHENTICATION_USER_PASS,
auth_data: password.into().into_bytes(), auth_data: password.into().into_bytes(),
} }
} }
pub fn with_access_token(token: impl Into<String>) -> Self {
Self {
username: None,
auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN,
auth_data: token.into().into_bytes(),
}
}
pub fn with_blob( pub fn with_blob(
username: impl Into<String>, username: impl Into<String>,
encrypted_blob: impl AsRef<[u8]>, encrypted_blob: impl AsRef<[u8]>,
device_id: impl AsRef<[u8]>, device_id: impl AsRef<[u8]>,
) -> Result<Credentials, Error> { ) -> Result<Self, Error> {
fn read_u8<R: Read>(stream: &mut R) -> io::Result<u8> { fn read_u8<R: Read>(stream: &mut R) -> io::Result<u8> {
let mut data = [0u8]; let mut data = [0u8];
stream.read_exact(&mut data)?; stream.read_exact(&mut data)?;
@ -136,8 +144,8 @@ impl Credentials {
read_u8(&mut cursor)?; read_u8(&mut cursor)?;
let auth_data = read_bytes(&mut cursor)?; let auth_data = read_bytes(&mut cursor)?;
Ok(Credentials { Ok(Self {
username, username: Some(username),
auth_type, auth_type,
auth_data, auth_data,
}) })

View file

@ -99,10 +99,12 @@ pub async fn authenticate(
}; };
let mut packet = ClientResponseEncrypted::new(); let mut packet = ClientResponseEncrypted::new();
packet if let Some(username) = credentials.username {
.login_credentials packet
.mut_or_insert_default() .login_credentials
.set_username(credentials.username); .mut_or_insert_default()
.set_username(username);
}
packet packet
.login_credentials .login_credentials
.mut_or_insert_default() .mut_or_insert_default()
@ -133,6 +135,7 @@ pub async fn authenticate(
let cmd = PacketType::Login; let cmd = PacketType::Login;
let data = packet.write_to_bytes()?; let data = packet.write_to_bytes()?;
debug!("Authenticating with AP using {:?}", credentials.auth_type);
transport.send((cmd as u8, data)).await?; transport.send((cmd as u8, data)).await?;
let (cmd, data) = transport let (cmd, data) = transport
.next() .next()
@ -144,7 +147,7 @@ pub async fn authenticate(
let welcome_data = APWelcome::parse_from_bytes(data.as_ref())?; let welcome_data = APWelcome::parse_from_bytes(data.as_ref())?;
let reusable_credentials = Credentials { 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_type: welcome_data.reusable_auth_credentials_type(),
auth_data: welcome_data.reusable_auth_credentials().to_owned(), auth_data: welcome_data.reusable_auth_credentials().to_owned(),
}; };

View file

@ -19,6 +19,8 @@ use tokio::sync::{
}; };
use url::ParseError; use url::ParseError;
use librespot_oauth::OAuthError;
#[cfg(feature = "with-dns-sd")] #[cfg(feature = "with-dns-sd")]
use dns_sd::DNSError; use dns_sd::DNSError;
@ -287,6 +289,25 @@ impl fmt::Display for Error {
} }
} }
impl From<OAuthError> 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<DecodeError> for Error { impl From<DecodeError> for Error {
fn from(err: DecodeError) -> Self { fn from(err: DecodeError) -> Self {
Self::new(ErrorKind::FailedPrecondition, err) Self::new(ErrorKind::FailedPrecondition, err)

76
core/src/session.rs Executable file → Normal file
View file

@ -13,6 +13,7 @@ use byteorder::{BigEndian, ByteOrder};
use bytes::Bytes; use bytes::Bytes;
use futures_core::TryStream; use futures_core::TryStream;
use futures_util::{future, ready, StreamExt, TryStreamExt}; use futures_util::{future, ready, StreamExt, TryStreamExt};
use librespot_protocol::authentication::AuthenticationType;
use num_traits::FromPrimitive; use num_traits::FromPrimitive;
use once_cell::sync::OnceCell; use once_cell::sync::OnceCell;
use parking_lot::RwLock; use parking_lot::RwLock;
@ -22,13 +23,13 @@ use tokio::{sync::mpsc, time::Instant};
use tokio_stream::wrappers::UnboundedReceiverStream; use tokio_stream::wrappers::UnboundedReceiverStream;
use crate::{ use crate::{
apresolve::ApResolver, apresolve::{ApResolver, SocketAddress},
audio_key::AudioKeyManager, audio_key::AudioKeyManager,
authentication::Credentials, authentication::Credentials,
cache::Cache, cache::Cache,
channel::ChannelManager, channel::ChannelManager,
config::SessionConfig, config::SessionConfig,
connection::{self, AuthenticationError}, connection::{self, AuthenticationError, Transport},
http_client::HttpClient, http_client::HttpClient,
mercury::MercuryManager, mercury::MercuryManager,
packet::PacketType, packet::PacketType,
@ -77,6 +78,7 @@ struct SessionData {
client_brand_name: String, client_brand_name: String,
client_model_name: String, client_model_name: String,
connection_id: String, connection_id: String,
auth_data: Vec<u8>,
time_delta: i64, time_delta: i64,
invalid: bool, invalid: bool,
user_data: UserData, 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( pub async fn connect(
&self, &self,
credentials: Credentials, credentials: Credentials,
@ -148,17 +190,8 @@ impl Session {
let (reusable_credentials, transport) = loop { let (reusable_credentials, transport) = loop {
let ap = self.apresolver().resolve("accesspoint").await?; let ap = self.apresolver().resolve("accesspoint").await?;
info!("Connecting to AP \"{}:{}\"", ap.0, ap.1); info!("Connecting to AP \"{}:{}\"", ap.0, ap.1);
let mut transport = match self.connect_inner(ap, credentials.clone()).await {
connection::connect(&ap.0, ap.1, self.config().proxy.as_ref()).await?; Ok(ct) => break ct,
match connection::authenticate(
&mut transport,
credentials.clone(),
&self.config().device_id,
)
.await
{
Ok(creds) => break (creds, transport),
Err(e) => { Err(e) => {
if let Some(AuthenticationError::LoginFailed(ErrorCode::TryAnotherAP)) = if let Some(AuthenticationError::LoginFailed(ErrorCode::TryAnotherAP)) =
e.error.downcast_ref::<AuthenticationError>() e.error.downcast_ref::<AuthenticationError>()
@ -172,8 +205,13 @@ impl Session {
} }
}; };
info!("Authenticated as \"{}\" !", reusable_credentials.username); let username = reusable_credentials
self.set_username(&reusable_credentials.username); .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 let Some(cache) = self.cache() {
if store_credentials { if store_credentials {
let cred_changed = cache let cred_changed = cache
@ -471,6 +509,14 @@ impl Session {
username.clone_into(&mut self.0.data.write().user_data.canonical_username); username.clone_into(&mut self.0.data.write().user_data.canonical_username);
} }
pub fn auth_data(&self) -> Vec<u8> {
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 { pub fn country(&self) -> String {
self.0.data.read().user_data.country.clone() self.0.data.read().user_data.country.clone()
} }

View file

@ -7,23 +7,34 @@ const SCOPES: &str =
#[tokio::main] #[tokio::main]
async fn 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(); let args: Vec<_> = env::args().collect();
if args.len() != 3 { if args.len() == 3 {
eprintln!("Usage: {} USERNAME PASSWORD", args[0]); // 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; return;
} }
let access_token = &args[1];
println!("Connecting..."); // Now create a new session with that token.
let credentials = Credentials::with_password(&args[1], &args[2]); let session = Session::new(session_config.clone(), None);
let session = Session::new(session_config, None); let credentials = Credentials::with_access_token(access_token);
println!("Connecting with token..");
match session.connect(credentials, false).await { match session.connect(credentials, false).await {
Ok(()) => println!( Ok(()) => println!("Session username: {:#?}", session.username()),
"Token: {:#?}", Err(e) => {
session.token_provider().get_token(SCOPES).await.unwrap() println!("Error connecting: {e}");
), return;
Err(e) => println!("Error connecting: {}", e), }
} };
let token = session.token_provider().get_token(SCOPES).await.unwrap();
println!("Got me a token: {token:#?}");
} }

View file

@ -22,13 +22,13 @@ async fn main() {
let audio_format = AudioFormat::default(); let audio_format = AudioFormat::default();
let args: Vec<_> = env::args().collect(); let args: Vec<_> = env::args().collect();
if args.len() != 4 { if args.len() != 3 {
eprintln!("Usage: {} USERNAME PASSWORD TRACK", args[0]); eprintln!("Usage: {} ACCESS_TOKEN TRACK", args[0]);
return; 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; track.item_type = SpotifyItemType::Track;
let backend = audio_backend::find(None).unwrap(); let backend = audio_backend::find(None).unwrap();

View file

@ -28,16 +28,16 @@ async fn main() {
let connect_config = ConnectConfig::default(); let connect_config = ConnectConfig::default();
let mut args: Vec<_> = env::args().collect(); let mut args: Vec<_> = env::args().collect();
let context_uri = if args.len() == 4 { let context_uri = if args.len() == 3 {
args.pop().unwrap() args.pop().unwrap()
} else if args.len() == 3 { } else if args.len() == 2 {
String::from("spotify:album:79dL7FLiJFOO0EoehUHQBv") String::from("spotify:album:79dL7FLiJFOO0EoehUHQBv")
} else { } else {
eprintln!("Usage: {} USERNAME PASSWORD (ALBUM URI)", args[0]); eprintln!("Usage: {} ACCESS_TOKEN (ALBUM URI)", args[0]);
return; 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(); let backend = audio_backend::find(None).unwrap();
println!("Connecting..."); println!("Connecting...");

View file

@ -13,13 +13,13 @@ async fn main() {
let session_config = SessionConfig::default(); let session_config = SessionConfig::default();
let args: Vec<_> = env::args().collect(); let args: Vec<_> = env::args().collect();
if args.len() != 4 { if args.len() != 3 {
eprintln!("Usage: {} USERNAME PASSWORD PLAYLIST", args[0]); eprintln!("Usage: {} ACCESS_TOKEN PLAYLIST", args[0]);
return; 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!( eprintln!(
"PLAYLIST should be a playlist URI such as: \ "PLAYLIST should be a playlist URI such as: \
\"spotify:playlist:37i9dQZF1DXec50AjHrNTq\"" \"spotify:playlist:37i9dQZF1DXec50AjHrNTq\""

18
oauth/Cargo.toml Normal file
View file

@ -0,0 +1,18 @@
[package]
name = "librespot-oauth"
version = "0.5.0-dev"
rust-version = "1.73"
authors = ["Nick Steel <nick@nsteel.co.uk>"]
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"] }

32
oauth/examples/oauth.rs Normal file
View file

@ -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::<Vec<&str>>(),
)
} else if args.len() == 1 {
(SPOTIFY_CLIENT_ID, SPOTIFY_REDIRECT_URI, vec!["streaming"])
} else {
eprintln!("Usage: {} [CLIENT_ID REDIRECT_URI SCOPES]", args[0]);
return;
};
match get_access_token(client_id, redirect_uri, scopes) {
Ok(token) => println!("Success: {token:#?}"),
Err(e) => println!("Failed: {e}"),
};
}

287
oauth/src/lib.rs Normal file
View file

@ -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<String>,
}
/// Return code query-string parameter from the redirect URI.
fn get_code(redirect_url: &str) -> Result<AuthorizationCode, OAuthError> {
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<AuthorizationCode, OAuthError> {
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<AuthorizationCode, OAuthError> {
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<SocketAddr> {
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<OAuthToken, OAuthError> {
let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string())
.map_err(|_| OAuthError::InvalidSpotifyUri)?;
let token_url = TokenUrl::new("https://accounts.spotify.com/api/token".to_string())
.map_err(|_| OAuthError::InvalidSpotifyUri)?;
let redirect_url =
RedirectUrl::new(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<oauth2::Scope> = 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<String> = 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)
);
}
}

View file

@ -6,7 +6,7 @@ DRY_RUN='false'
WORKINGDIR="$( cd "$(dirname "$0")" ; pwd -P )" WORKINGDIR="$( cd "$(dirname "$0")" ; pwd -P )"
cd $WORKINGDIR cd $WORKINGDIR
crates=( "protocol" "core" "discovery" "audio" "metadata" "playback" "connect" "librespot" ) crates=( "protocol" "core" "discovery" "oauth" "audio" "metadata" "playback" "connect" "librespot" )
OS=`uname` OS=`uname`
function replace_in_file() { function replace_in_file() {

View file

@ -5,5 +5,6 @@ pub use librespot_connect as connect;
pub use librespot_core as core; pub use librespot_core as core;
pub use librespot_discovery as discovery; pub use librespot_discovery as discovery;
pub use librespot_metadata as metadata; pub use librespot_metadata as metadata;
pub use librespot_oauth as oauth;
pub use librespot_playback as playback; pub use librespot_playback as playback;
pub use librespot_protocol as protocol; pub use librespot_protocol as protocol;

View file

@ -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 { struct Setup {
format: AudioFormat, format: AudioFormat,
backend: SinkBuilder, backend: SinkBuilder,
@ -179,6 +210,8 @@ struct Setup {
connect_config: ConnectConfig, connect_config: ConnectConfig,
mixer_config: MixerConfig, mixer_config: MixerConfig,
credentials: Option<Credentials>, credentials: Option<Credentials>,
enable_oauth: bool,
oauth_port: Option<u16>,
enable_discovery: bool, enable_discovery: bool,
zeroconf_port: u16, zeroconf_port: u16,
player_event_program: Option<String>, player_event_program: Option<String>,
@ -195,6 +228,7 @@ fn get_setup() -> Setup {
const VALID_NORMALISATION_ATTACK_RANGE: RangeInclusive<u64> = 1..=500; const VALID_NORMALISATION_ATTACK_RANGE: RangeInclusive<u64> = 1..=500;
const VALID_NORMALISATION_RELEASE_RANGE: RangeInclusive<u64> = 1..=1000; const VALID_NORMALISATION_RELEASE_RANGE: RangeInclusive<u64> = 1..=1000;
const ACCESS_TOKEN: &str = "access-token";
const AP_PORT: &str = "ap-port"; const AP_PORT: &str = "ap-port";
const AUTOPLAY: &str = "autoplay"; const AUTOPLAY: &str = "autoplay";
const BACKEND: &str = "backend"; const BACKEND: &str = "backend";
@ -210,6 +244,7 @@ fn get_setup() -> Setup {
const DISABLE_GAPLESS: &str = "disable-gapless"; const DISABLE_GAPLESS: &str = "disable-gapless";
const DITHER: &str = "dither"; const DITHER: &str = "dither";
const EMIT_SINK_EVENTS: &str = "emit-sink-events"; const EMIT_SINK_EVENTS: &str = "emit-sink-events";
const ENABLE_OAUTH: &str = "enable-oauth";
const ENABLE_VOLUME_NORMALISATION: &str = "enable-volume-normalisation"; const ENABLE_VOLUME_NORMALISATION: &str = "enable-volume-normalisation";
const FORMAT: &str = "format"; const FORMAT: &str = "format";
const HELP: &str = "help"; const HELP: &str = "help";
@ -226,6 +261,7 @@ fn get_setup() -> Setup {
const NORMALISATION_PREGAIN: &str = "normalisation-pregain"; const NORMALISATION_PREGAIN: &str = "normalisation-pregain";
const NORMALISATION_RELEASE: &str = "normalisation-release"; const NORMALISATION_RELEASE: &str = "normalisation-release";
const NORMALISATION_THRESHOLD: &str = "normalisation-threshold"; const NORMALISATION_THRESHOLD: &str = "normalisation-threshold";
const OAUTH_PORT: &str = "oauth-port";
const ONEVENT: &str = "onevent"; const ONEVENT: &str = "onevent";
#[cfg(feature = "passthrough-decoder")] #[cfg(feature = "passthrough-decoder")]
const PASSTHROUGH: &str = "passthrough"; const PASSTHROUGH: &str = "passthrough";
@ -260,6 +296,9 @@ fn get_setup() -> Setup {
const DISABLE_CREDENTIAL_CACHE_SHORT: &str = "H"; const DISABLE_CREDENTIAL_CACHE_SHORT: &str = "H";
const HELP_SHORT: &str = "h"; const HELP_SHORT: &str = "h";
const ZEROCONF_INTERFACE_SHORT: &str = "i"; 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 CACHE_SIZE_LIMIT_SHORT: &str = "M";
const MIXER_TYPE_SHORT: &str = "m"; const MIXER_TYPE_SHORT: &str = "m";
const ENABLE_VOLUME_NORMALISATION_SHORT: &str = "N"; const ENABLE_VOLUME_NORMALISATION_SHORT: &str = "N";
@ -381,6 +420,11 @@ fn get_setup() -> Setup {
ENABLE_VOLUME_NORMALISATION, ENABLE_VOLUME_NORMALISATION,
"Play all tracks at approximately the same apparent volume.", "Play all tracks at approximately the same apparent volume.",
) )
.optflag(
ENABLE_OAUTH_SHORT,
ENABLE_OAUTH,
"Perform interactive OAuth sign in.",
)
.optopt( .optopt(
NAME_SHORT, NAME_SHORT,
NAME, NAME,
@ -457,6 +501,18 @@ fn get_setup() -> Setup {
"Password used to sign in with.", "Password used to sign in with.",
"PASSWORD", "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( .optopt(
ONEVENT_SHORT, ONEVENT_SHORT,
ONEVENT, ONEVENT,
@ -670,7 +726,10 @@ fn get_setup() -> Setup {
trace!("Environment variable(s):"); trace!("Environment variable(s):");
for (k, v) in &env_vars { 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\""); trace!("\t\t{k}=\"XXXXXXXX\"");
} else if v.is_empty() { } else if v.is_empty() {
trace!("\t\t{k}="); trace!("\t\t{k}=");
@ -702,7 +761,15 @@ fn get_setup() -> Setup {
&& matches.opt_defined(opt) && matches.opt_defined(opt)
&& matches.opt_present(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. // Don't log creds.
trace!("\t\t{opt} \"XXXXXXXX\""); trace!("\t\t{opt} \"XXXXXXXX\"");
} else { } else {
@ -1081,44 +1148,32 @@ fn get_setup() -> Setup {
} }
}; };
let enable_oauth = opt_present(ENABLE_OAUTH);
let credentials = { let credentials = {
let cached_creds = cache.as_ref().and_then(Cache::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() { if username.is_empty() {
empty_string_error_msg(USERNAME, USERNAME_SHORT); empty_string_error_msg(USERNAME, USERNAME_SHORT);
} }
if let Some(password) = opt_str(PASSWORD) { if opt_present(PASSWORD) {
if password.is_empty() { error!("Invalid `--{PASSWORD}` / `-{PASSWORD_SHORT}`: Password authentication no longer supported, use OAuth");
empty_string_error_msg(PASSWORD, PASSWORD_SHORT); 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 { trace!("No cached credentials for specified username.");
match cached_creds { None
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
}
}
}
} }
} }
} else { } else {
@ -1131,11 +1186,39 @@ fn get_setup() -> Setup {
let enable_discovery = !opt_present(DISABLE_DISCOVERY); let enable_discovery = !opt_present(DISABLE_DISCOVERY);
if credentials.is_none() && !enable_discovery { if credentials.is_none() && !enable_discovery && !enable_oauth {
error!("Credentials are required if discovery is disabled."); error!("Credentials are required if discovery and oauth login are disabled.");
exit(1); 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::<u16>() {
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) { if !enable_discovery && opt_present(ZEROCONF_PORT) {
warn!( warn!(
"With the `--{}` / `-{}` flag set `--{}` / `-{}` has no effect.", "With the `--{}` / `-{}` flag set `--{}` / `-{}` has no effect.",
@ -1643,6 +1726,8 @@ fn get_setup() -> Setup {
connect_config, connect_config,
mixer_config, mixer_config,
credentials, credentials,
enable_oauth,
oauth_port,
enable_discovery, enable_discovery,
zeroconf_port, zeroconf_port,
player_event_program, player_event_program,
@ -1718,6 +1803,24 @@ async fn main() {
if let Some(credentials) = setup.credentials { if let Some(credentials) = setup.credentials {
last_credentials = Some(credentials); last_credentials = Some(credentials);
connecting = true; 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() { } else if discovery.is_none() {
error!( error!(
"Discovery is unavailable and no credentials provided. Authentication is not possible." "Discovery is unavailable and no credentials provided. Authentication is not possible."