Skip to content

Commit

Permalink
Get access token via login5 (#1344)
Browse files Browse the repository at this point in the history
* core: Obtain spclient access token using login5 instead of keymaster (Fixes #1179)
* core: move solving hashcash into util
* login5: add login for mobile

---------

Co-authored-by: Nick Steel <[email protected]>
  • Loading branch information
photovoltex and kingosticks authored Oct 19, 2024
1 parent d8e8423 commit 4580dab
Show file tree
Hide file tree
Showing 9 changed files with 362 additions and 55 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- [core] The `access_token` for http requests is now acquired by `login5`

### Added

### Changed
- [core] Add `login` (mobile) and `auth_token` retrieval via login5

### Removed

Expand Down
20 changes: 19 additions & 1 deletion connect/src/spirc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,9 @@ impl Spirc {
}),
);

// pre-acquire client_token, preventing multiple request while running
let _ = session.spclient().client_token().await?;

// Connect *after* all message listeners are registered
session.connect(credentials, true).await?;

Expand Down Expand Up @@ -490,7 +493,22 @@ impl SpircTask {
},
connection_id_update = self.connection_id_update.next() => match connection_id_update {
Some(result) => match result {
Ok(connection_id) => self.handle_connection_id_update(connection_id),
Ok(connection_id) => {
self.handle_connection_id_update(connection_id);

// pre-acquire access_token, preventing multiple request while running
// pre-acquiring for the access_token will only last for one hour
//
// we need to fire the request after connecting, but can't do it right
// after, because by that we would miss certain packages, like this one
match self.session.login5().auth_token().await {
Ok(_) => debug!("successfully pre-acquire access_token and client_token"),
Err(why) => {
error!("{why}");
break
}
}
},
Err(e) => error!("could not parse connection ID update: {}", e),
}
None => {
Expand Down
2 changes: 2 additions & 0 deletions core/src/http_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ impl HttpClient {
let os_version = System::os_version().unwrap_or_else(|| zero_str.clone());

let (spotify_platform, os_version) = match OS {
// example os_version: 30
"android" => ("Android", os_version),
// example os_version: 17
"ios" => ("iOS", os_version),
"macos" => ("OSX", zero_str),
"windows" => ("Win32", zero_str),
Expand Down
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
265 changes: 265 additions & 0 deletions core/src/login5.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
use crate::spclient::CLIENT_TOKEN;
use crate::token::Token;
use crate::{util, Error, SessionConfig};
use bytes::Bytes;
use http::{header::ACCEPT, HeaderValue, Method, Request};
use librespot_protocol::login5::login_response::Response;
use librespot_protocol::{
client_info::ClientInfo,
credentials::{Password, StoredCredential},
hashcash::HashcashSolution,
login5::{
login_request::Login_method, ChallengeSolution, LoginError, LoginOk, 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("Login request was denied: {0:?}")]
FaultyRequest(LoginError),
#[error("Code challenge is not supported")]
CodeChallenge,
#[error("Tried to acquire token without stored credentials")]
NoStoredCredentials,
#[error("Couldn't successfully authenticate after {0} times")]
RetriesFailed(u8),
#[error("Login via login5 is only allowed for android or ios")]
OnlyForMobile,
}

impl From<Login5Error> for Error {
fn from(err: Login5Error) -> Self {
match err {
Login5Error::NoStoredCredentials | Login5Error::OnlyForMobile => {
Error::unavailable(err)
}
Login5Error::RetriesFailed(_) | Login5Error::FaultyRequest(_) => {
Error::failed_precondition(err)
}
Login5Error::CodeChallenge => Error::unimplemented(err),
}
}
}

impl Login5Manager {
async fn 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
}

async fn login5_request(&self, login: Login_method) -> Result<LoginOk, Error> {
let client_id = match OS {
"macos" | "windows" => self.session().client_id(),
_ => SessionConfig::default().client_id,
};

let mut login_request = LoginRequest {
client_info: MessageField::some(ClientInfo {
client_id,
device_id: self.session().device_id().to_string(),
special_fields: Default::default(),
}),
login_method: Some(login),
..Default::default()
};

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

loop {
count += 1;

let message = LoginResponse::parse_from_bytes(&response)?;
if let Some(Response::Ok(ok)) = message.response {
break Ok(ok);
}

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() {
// handles the challenges, and updates the login context with the response
Self::handle_challenges(&mut login_request, message)?;
}

if count < MAX_LOGIN_TRIES {
response = self.request(&login_request).await?;
} else {
return Err(Login5Error::RetriesFailed(MAX_LOGIN_TRIES).into());
}
}
}

/// Login for android and ios
///
/// This request doesn't require a connected session as it is the entrypoint for android or ios
///
/// This request will only work when:
/// - client_id => android or ios | can be easily adjusted in [SessionConfig::default_for_os]
/// - user-agent => android or ios | has to be adjusted in [HttpClient::new](crate::http_client::HttpClient::new)
pub async fn login(
&self,
id: impl Into<String>,
password: impl Into<String>,
) -> Result<(Token, Vec<u8>), Error> {
if !matches!(OS, "android" | "ios") {
// by manipulating the user-agent and client-id it can be also used/tested on desktop
return Err(Login5Error::OnlyForMobile.into());
}

let method = Login_method::Password(Password {
id: id.into(),
password: password.into(),
..Default::default()
});

let token_response = self.login5_request(method).await?;
let auth_token = Self::token_from_login(
token_response.access_token,
token_response.access_token_expires_in,
);

Ok((auth_token, token_response.stored_credential))
}

/// Retrieve the access_token via login5
///
/// This request will only work when the store credentials match the client-id. Meaning that
/// stored credentials generated with the keymaster client-id will not work, for example, with
/// the android client-id.
pub async fn auth_token(&self) -> Result<Token, Error> {
let auth_data = self.session().auth_data();
if auth_data.is_empty() {
return Err(Login5Error::NoStoredCredentials.into());
}

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 method = Login_method::StoredCredential(StoredCredential {
username: self.session().username().to_string(),
data: auth_data,
..Default::default()
});

let token_response = self.login5_request(method).await?;
let auth_token = Self::token_from_login(
token_response.access_token,
token_response.access_token_expires_in,
);

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

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

token.ok_or(Login5Error::NoStoredCredentials.into())
}

fn handle_challenges(
login_request: &mut LoginRequest,
message: LoginResponse,
) -> Result<(), Error> {
let challenges = message.challenges();
debug!(
"Received {} challenges, solving...",
challenges.challenges.len()
);

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

let hash_cash_challenge = challenge.hashcash();

let mut suffix = [0u8; 0x10];
let duration = util::solve_hash_cash(
&message.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);
debug!("Solving hashcash took {seconds}s {nanos}ns");

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);
}

login_request.login_context = message.login_context;

Ok(())
}

fn token_from_login(token: String, expires_in: i32) -> Token {
Token {
access_token: token,
expires_in: Duration::from_secs(expires_in.try_into().unwrap_or(3600)),
token_type: "Bearer".to_string(),
scopes: vec![],
timestamp: Instant::now(),
}
}
}
9 changes: 9 additions & 0 deletions core/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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 @@ -101,6 +102,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 @@ -141,6 +143,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 @@ -310,6 +313,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()))
}

pub fn time_delta(&self) -> i64 {
self.0.data.read().time_delta
}
Expand Down
Loading

0 comments on commit 4580dab

Please sign in to comment.