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

Get access token via login5 #1344

Merged
merged 13 commits into from
Oct 19, 2024
Merged
1 change: 1 addition & 0 deletions core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub mod diffie_hellman;
pub mod error;
pub mod file_id;
pub mod http_client;
pub mod login5;
pub mod mercury;
pub mod packet;
mod proxytunnel;
Expand Down
190 changes: 190 additions & 0 deletions core/src/login5.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
use crate::spclient::CLIENT_TOKEN;
use crate::token::Token;
use crate::{util, Error, SessionConfig};
use bytes::Bytes;
use http::header::ACCEPT;
use http::{HeaderValue, Method, Request};
use librespot_protocol::hashcash::HashcashSolution;
use librespot_protocol::login5::{
ChallengeSolution, Challenges, LoginError, LoginRequest, LoginResponse,
};
use protobuf::well_known_types::duration::Duration as ProtoDuration;
use protobuf::{Message, MessageField};
use std::env::consts::OS;
use std::time::{Duration, Instant};
use thiserror::Error;
use tokio::time::sleep;

const MAX_LOGIN_TRIES: u8 = 3;
const LOGIN_TIMEOUT: Duration = Duration::from_secs(3);

component! {
Login5Manager : Login5ManagerInner {
auth_token: Option<Token> = None,
}
}

#[derive(Debug, Error)]
enum Login5Error {
#[error("Requesting login failed: {0:?}")]
FaultyRequest(LoginError),
#[error("doesn't support code challenge")]
CodeChallenge,
}

impl From<Login5Error> for Error {
fn from(err: Login5Error) -> Self {
Error::failed_precondition(err)
}
}

impl Login5Manager {
async fn auth_token_request(&self, message: &LoginRequest) -> Result<Bytes, Error> {
let client_token = self.session().spclient().client_token().await?;
let body = message.write_to_bytes()?;

let request = Request::builder()
.method(&Method::POST)
.uri("https://login5.spotify.com/v3/login")
.header(ACCEPT, HeaderValue::from_static("application/x-protobuf"))
.header(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?)
.body(body.into())?;

self.session().http_client().request_body(request).await
}

pub async fn auth_token(&self) -> Result<Token, Error> {
let auth_token = self.lock(|inner| {
if let Some(token) = &inner.auth_token {
if token.is_expired() {
inner.auth_token = None;
}
}
inner.auth_token.clone()
});

if let Some(auth_token) = auth_token {
return Ok(auth_token);
}

let client_id = match OS {
"macos" | "windows" => self.session().client_id(),
_ => SessionConfig::default().client_id,
};

let mut login_request = LoginRequest::new();
login_request.client_info.mut_or_insert_default().client_id = client_id;
login_request.client_info.mut_or_insert_default().device_id =
self.session().device_id().to_string();

let stored_credential = login_request.mut_stored_credential();
stored_credential.username = self.session().username().to_string();
stored_credential.data = self.session().auth_data().clone();

let mut response = self.auth_token_request(&login_request).await?;
let mut count = 0;

let token_response = loop {
count += 1;

let message = LoginResponse::parse_from_bytes(&response)?;
if message.has_ok() {
break message.ok().to_owned();
}

if message.has_error() {
match message.error() {
LoginError::TIMEOUT | LoginError::TOO_MANY_ATTEMPTS => {
sleep(LOGIN_TIMEOUT).await
}
others => return Err(Login5Error::FaultyRequest(others).into()),
}
}

if message.has_challenges() {
Self::handle_challenges(&mut login_request, message.challenges())?
}

if count < MAX_LOGIN_TRIES {
response = self.auth_token_request(&login_request).await?;
} else {
return Err(Error::failed_precondition(format!(
"Unable to solve any of {MAX_LOGIN_TRIES} hash cash challenges"
)));
}
};

let auth_token = Token {
access_token: token_response.access_token.clone(),
expires_in: Duration::from_secs(
token_response
.access_token_expires_in
.try_into()
.unwrap_or(3600),
),
token_type: "Bearer".to_string(),
scopes: vec![],
timestamp: Instant::now(),
};

self.lock(|inner| {
inner.auth_token = Some(auth_token.clone());
});

trace!("Got auth token: {:?}", auth_token);

Ok(auth_token)
}

fn handle_challenges(
login_request: &mut LoginRequest,
challenges: &Challenges,
) -> Result<(), Error> {
info!(
"login5 response has {} challenges...",
challenges.challenges.len()
);

for challenge in &challenges.challenges {
if challenge.has_code() {
debug!("empty challenge, skipping");
return Err(Login5Error::CodeChallenge.into());
} else if !challenge.has_hashcash() {
debug!("empty challenge, skipping");
continue;
}

let hash_cash_challenge = challenge.hashcash();

let mut suffix = [0u8; 0x10];
let duration = util::solve_hash_cash(
&login_request.login_context,
&hash_cash_challenge.prefix,
hash_cash_challenge.length,
&mut suffix,
)?;

let (seconds, nanos) = (duration.as_secs() as i64, duration.subsec_nanos() as i32);
info!("solving login5 hashcash took {seconds}.{nanos}s");

let mut solution = ChallengeSolution::new();
solution.set_hashcash(HashcashSolution {
suffix: Vec::from(suffix),
duration: MessageField::some(ProtoDuration {
seconds,
nanos,
..Default::default()
}),
..Default::default()
});

login_request
.challenge_solutions
.mut_or_insert_default()
.solutions
.push(solution);
}

Ok(())
}
}
9 changes: 9 additions & 0 deletions core/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ use crate::{
config::SessionConfig,
connection::{self, AuthenticationError, Transport},
http_client::HttpClient,
login5::Login5Manager,
mercury::MercuryManager,
packet::PacketType,
protocol::keyexchange::ErrorCode,
Expand Down Expand Up @@ -98,6 +99,7 @@ struct SessionInternal {
mercury: OnceCell<MercuryManager>,
spclient: OnceCell<SpClient>,
token_provider: OnceCell<TokenProvider>,
login5: OnceCell<Login5Manager>,
cache: Option<Arc<Cache>>,

handle: tokio::runtime::Handle,
Expand Down Expand Up @@ -138,6 +140,7 @@ impl Session {
mercury: OnceCell::new(),
spclient: OnceCell::new(),
token_provider: OnceCell::new(),
login5: OnceCell::new(),
handle: tokio::runtime::Handle::current(),
}))
}
Expand Down Expand Up @@ -286,6 +289,12 @@ impl Session {
.get_or_init(|| TokenProvider::new(self.weak()))
}

pub fn login5(&self) -> &Login5Manager {
self.0
.login5
.get_or_init(|| Login5Manager::new(self.weak()))
}

/// Returns an error, when we haven't received a ping for too long (2 minutes),
/// which means that we silently lost connection to Spotify servers.
async fn session_timeout(session: SessionWeak) -> io::Result<()> {
Expand Down
54 changes: 4 additions & 50 deletions core/src/spclient.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use std::{
time::{Duration, Instant},
};

use byteorder::{BigEndian, ByteOrder};
use bytes::Bytes;
use data_encoding::HEXUPPER_PERMISSIVE;
use futures_util::future::IntoStream;
Expand All @@ -16,7 +15,6 @@ use hyper::{
use hyper_util::client::legacy::ResponseFuture;
use protobuf::{Enum, Message, MessageFull};
use rand::RngCore;
use sha1::{Digest, Sha1};
use sysinfo::System;
use thiserror::Error;

Expand All @@ -35,6 +33,7 @@ use crate::{
extended_metadata::BatchedEntityRequest,
},
token::Token,
util,
version::spotify_semantic_version,
Error, FileId, SpotifyId,
};
Expand All @@ -50,7 +49,7 @@ component! {
pub type SpClientResult = Result<Bytes, Error>;

#[allow(clippy::declare_interior_mutable_const)]
const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token");
pub const CLIENT_TOKEN: HeaderName = HeaderName::from_static("client-token");

#[derive(Debug, Error)]
pub enum SpClientError {
Expand Down Expand Up @@ -108,47 +107,6 @@ impl SpClient {
Ok(format!("https://{}:{}", ap.0, ap.1))
}

fn solve_hash_cash(
ctx: &[u8],
prefix: &[u8],
length: i32,
dst: &mut [u8],
) -> Result<(), Error> {
// after a certain number of seconds, the challenge expires
const TIMEOUT: u64 = 5; // seconds
let now = Instant::now();

let md = Sha1::digest(ctx);

let mut counter: i64 = 0;
let target: i64 = BigEndian::read_i64(&md[12..20]);

let suffix = loop {
if now.elapsed().as_secs() >= TIMEOUT {
return Err(Error::deadline_exceeded(format!(
"{TIMEOUT} seconds expired"
)));
}

let suffix = [(target + counter).to_be_bytes(), counter.to_be_bytes()].concat();

let mut hasher = Sha1::new();
hasher.update(prefix);
hasher.update(&suffix);
let md = hasher.finalize();

if BigEndian::read_i64(&md[12..20]).trailing_zeros() >= (length as u32) {
break suffix;
}

counter += 1;
};

dst.copy_from_slice(&suffix);

Ok(())
}

async fn client_token_request<M: Message>(&self, message: &M) -> Result<Bytes, Error> {
let body = message.write_to_bytes()?;

Expand Down Expand Up @@ -293,7 +251,7 @@ impl SpClient {
let length = hash_cash_challenge.length;

let mut suffix = [0u8; 0x10];
let answer = Self::solve_hash_cash(&ctx, &prefix, length, &mut suffix);
let answer = util::solve_hash_cash(&ctx, &prefix, length, &mut suffix);

match answer {
Ok(_) => {
Expand Down Expand Up @@ -468,11 +426,7 @@ impl SpClient {
.body(body.to_owned().into())?;

// Reconnection logic: keep getting (cached) tokens because they might have expired.
let token = self
.session()
.token_provider()
.get_token("playlist-read")
.await?;
let token = self.session().login5().auth_token().await?;
roderickvd marked this conversation as resolved.
Show resolved Hide resolved

let headers_mut = request.headers_mut();
if let Some(ref hdrs) = headers {
Expand Down
Loading