Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Credentials with access token (oauth) #1309

Merged
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ version = "0.5.0-dev"
path = "protocol"
version = "0.5.0-dev"

[dependencies.librespot-oauth]
kingosticks marked this conversation as resolved.
Show resolved Hide resolved
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"] }
Expand Down
22 changes: 15 additions & 7 deletions core/src/authentication.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ impl From<AuthenticationError> 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<String>,

#[serde(serialize_with = "serialize_protobuf_enum")]
#[serde(deserialize_with = "deserialize_protobuf_enum")]
Expand All @@ -50,19 +50,27 @@ impl Credentials {
///
/// let creds = Credentials::with_password("my account", "my password");
/// ```
pub fn with_password(username: impl Into<String>, password: impl Into<String>) -> Credentials {
Credentials {
username: username.into(),
pub fn with_password(username: impl Into<String>, password: impl Into<String>) -> 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<String>) -> Self {
Self {
username: None,
auth_type: AuthenticationType::AUTHENTICATION_SPOTIFY_TOKEN,
auth_data: token.into().into_bytes(),
}
}

pub fn with_blob(
username: impl Into<String>,
encrypted_blob: 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> {
let mut data = [0u8];
stream.read_exact(&mut data)?;
Expand Down Expand Up @@ -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,
})
Expand Down
13 changes: 8 additions & 5 deletions core/src/connection/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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(),
};
Expand Down
18 changes: 16 additions & 2 deletions core/src/session.rs
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ struct SessionData {
client_brand_name: String,
client_model_name: String,
connection_id: String,
auth_data: Vec<u8>,
roderickvd marked this conversation as resolved.
Show resolved Hide resolved
time_delta: i64,
invalid: bool,
user_data: UserData,
Expand Down Expand Up @@ -172,8 +173,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
Expand Down Expand Up @@ -471,6 +477,14 @@ impl Session {
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 {
self.0.data.read().user_data.country.clone()
}
Expand Down
50 changes: 37 additions & 13 deletions examples/get_token.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,53 @@
use std::env;

use librespot::core::{authentication::Credentials, config::SessionConfig, session::Session};
use librespot::protocol::authentication::AuthenticationType;

const SCOPES: &str =
"streaming,user-read-playback-state,user-modify-playback-state,user-read-currently-playing";

#[tokio::main]
async fn main() {
let session_config = SessionConfig::default();
let mut session_config = SessionConfig::default();

let args: Vec<_> = env::args().collect();
if args.len() != 3 {
eprintln!("Usage: {} USERNAME PASSWORD", args[0]);
if args.len() == 3 {
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;
}
};

// This will fail. You can't use keymaster from an access token authed session.
// let token = session.token_provider().get_token(SCOPES).await.unwrap();

// Instead, derive stored credentials and auth a new session using those.
let stored_credentials = Credentials {
username: Some(session.username()),
auth_type: AuthenticationType::AUTHENTICATION_STORED_SPOTIFY_CREDENTIALS,
auth_data: session.auth_data(),
};
let session2 = Session::new(session_config, None);
match session2.connect(stored_credentials, false).await {
Ok(()) => println!("Session username: {:#?}", session2.username()),
Err(e) => {
println!("Error connecting: {}", e);
return;
}
};
let token = session2.token_provider().get_token(SCOPES).await.unwrap();
println!("Got me a token: {token:#?}");
}
18 changes: 18 additions & 0 deletions oauth/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "librespot-oauth"
version = "0.5.0-dev"
rust-version = "1.73"
authors = ["Paul Lietar <[email protected]>"]
kingosticks marked this conversation as resolved.
Show resolved Hide resolved
description = "Spotify OAuth"
kingosticks marked this conversation as resolved.
Show resolved Hide resolved
license = "MIT"
repository = "https://github.com/librespot-org/librespot"
edition = "2021"

[dependencies]
log = "0.4"
oauth2 = "4.4"
url = "2.2"

[dependencies.librespot-core]
path = "../core"
version = "0.5.0-dev"
152 changes: 152 additions & 0 deletions oauth/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
use log::{debug, error, info, trace};
use oauth2::reqwest::http_client;
use oauth2::{
basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge,
RedirectUrl, Scope, TokenResponse, TokenUrl,
};
use std::io;
use std::{
io::{BufRead, BufReader, Write},
net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener},
process::exit,
sync::mpsc,
};
use url::Url;

fn get_code(redirect_url: &str) -> AuthorizationCode {
let url = Url::parse(redirect_url).unwrap();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving from 0.4 to 0.5 I spent a lot of time making the code panic-free by purging all unwrap and expects. Could you have this and the below return Result too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I worked through most of this last night. As a part-time Rust contributor, I'm guilty of unwrapping everything rather than handling errors properly so this stuff was a bit new. I probably haven't got it as nice as it could be but it's better.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looked through it. Unless I'm mistaken, I still see quite a lot of code that could panic due to unwrapping. How can I help you set it up to return e.g. a Result<AuthorizationCode, core::Error> (the other library parts should offer examples on how to but let me know if you need some pointers).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've worked on doing it but I've yet to push that work as I didn't quite finish. I'll try and sort it now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There we go

let code = url
.query_pairs()
.find(|(key, _)| key == "code")
.map(|(_, code)| AuthorizationCode::new(code.into_owned()))
.unwrap();
code
}

fn get_authcode_stdin() -> AuthorizationCode {
println!("Provide redirect URL");
let mut buffer = String::new();
let stdin = io::stdin(); // We get `Stdin` here.
stdin.read_line(&mut buffer).unwrap();

get_code(buffer.trim())
}

fn get_authcode_listener(socket_address: SocketAddr) -> AuthorizationCode {
// A very naive implementation of the redirect server.
let listener = TcpListener::bind(socket_address).unwrap();

info!("OAuth server listening on {:?}", socket_address);

// The server will terminate itself after collecting the first code.
let Some(mut stream) = listener.incoming().flatten().next() else {
panic!("listener terminated without accepting a connection");
};

let mut reader = BufReader::new(&stream);

let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();

let redirect_url = request_line.split_whitespace().nth(1).unwrap();
let 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()).unwrap();

code
}

// TODO: Return a Result?
// TODO: Pass in redirect_address instead since the redirect host depends on client ID?
// TODO: Pass in scopes.
pub fn get_access_token(client_id: &str, redirect_port: u16) -> String {
// Must use host 127.0.0.1 with Spotify Desktop client ID.
let redirect_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), redirect_port);
let redirect_uri = format!("http://{redirect_address}/login");

let client = BasicClient::new(
ClientId::new(client_id.to_string()),
None,
AuthUrl::new("https://accounts.spotify.com/authorize".to_string()).unwrap(),
Some(TokenUrl::new("https://accounts.spotify.com/api/token".to_string()).unwrap()),
)
.set_redirect_uri(RedirectUrl::new(redirect_uri).expect("Invalid redirect URL"));

let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();

// Generate the full authorization URL.
// Some of these scopes are unavailable for custom client IDs. Which?
let scopes = vec![
"app-remote-control",
"playlist-modify",
"playlist-modify-private",
"playlist-modify-public",
"playlist-read",
"playlist-read-collaborative",
"playlist-read-private",
"streaming",
"ugc-image-upload",
"user-follow-modify",
"user-follow-read",
"user-library-modify",
"user-library-read",
"user-modify",
"user-modify-playback-state",
"user-modify-private",
"user-personalized",
"user-read-birthdate",
"user-read-currently-playing",
"user-read-email",
"user-read-play-history",
"user-read-playback-position",
"user-read-playback-state",
"user-read-private",
"user-read-recently-played",
"user-top-read",
];
let scopes: Vec<oauth2::Scope> = scopes.into_iter().map(|s| Scope::new(s.into())).collect();
let (auth_url, _) = client
.authorize_url(CsrfToken::new_random)
.add_scopes(scopes)
.set_pkce_challenge(pkce_challenge)
.url();

println!("Browse to: {}", auth_url);

let code = if redirect_port > 0 {
get_authcode_listener(redirect_address)
} else {
get_authcode_stdin()
};
debug!("Exchange {code:?} for access token");

// Do this sync in another thread because I am too stupid to make the async version work.
let (tx, rx) = mpsc::channel();
let client_clone = client.clone();
std::thread::spawn(move || {
let resp = client_clone
.exchange_code(code)
.set_pkce_verifier(pkce_verifier)
.request(http_client);
tx.send(resp).unwrap();
});
let token_response = rx.recv().unwrap();
let token = match token_response {
Ok(tok) => {
trace!("Obtained new access token: {tok:?}");
tok
}
Err(e) => {
error!("Failed to exchange code for access token: {e:?}");
exit(1);
}
};

token.access_token().secret().to_string()
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading