Skip to content

Commit 4580dab

Browse files
Get access token via login5 (#1344)
* 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]>
1 parent d8e8423 commit 4580dab

9 files changed

Lines changed: 362 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- [core] The `access_token` for http requests is now acquired by `login5`
13+
1014
### Added
1115

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

1418
### Removed
1519

connect/src/spirc.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,9 @@ impl Spirc {
337337
}),
338338
);
339339

340+
// pre-acquire client_token, preventing multiple request while running
341+
let _ = session.spclient().client_token().await?;
342+
340343
// Connect *after* all message listeners are registered
341344
session.connect(credentials, true).await?;
342345

@@ -490,7 +493,22 @@ impl SpircTask {
490493
},
491494
connection_id_update = self.connection_id_update.next() => match connection_id_update {
492495
Some(result) => match result {
493-
Ok(connection_id) => self.handle_connection_id_update(connection_id),
496+
Ok(connection_id) => {
497+
self.handle_connection_id_update(connection_id);
498+
499+
// pre-acquire access_token, preventing multiple request while running
500+
// pre-acquiring for the access_token will only last for one hour
501+
//
502+
// we need to fire the request after connecting, but can't do it right
503+
// after, because by that we would miss certain packages, like this one
504+
match self.session.login5().auth_token().await {
505+
Ok(_) => debug!("successfully pre-acquire access_token and client_token"),
506+
Err(why) => {
507+
error!("{why}");
508+
break
509+
}
510+
}
511+
},
494512
Err(e) => error!("could not parse connection ID update: {}", e),
495513
}
496514
None => {

core/src/http_client.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ impl HttpClient {
109109
let os_version = System::os_version().unwrap_or_else(|| zero_str.clone());
110110

111111
let (spotify_platform, os_version) = match OS {
112+
// example os_version: 30
112113
"android" => ("Android", os_version),
114+
// example os_version: 17
113115
"ios" => ("iOS", os_version),
114116
"macos" => ("OSX", zero_str),
115117
"windows" => ("Win32", zero_str),

core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ pub mod diffie_hellman;
2222
pub mod error;
2323
pub mod file_id;
2424
pub mod http_client;
25+
pub mod login5;
2526
pub mod mercury;
2627
pub mod packet;
2728
mod proxytunnel;

core/src/login5.rs

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
use crate::spclient::CLIENT_TOKEN;
2+
use crate::token::Token;
3+
use crate::{util, Error, SessionConfig};
4+
use bytes::Bytes;
5+
use http::{header::ACCEPT, HeaderValue, Method, Request};
6+
use librespot_protocol::login5::login_response::Response;
7+
use librespot_protocol::{
8+
client_info::ClientInfo,
9+
credentials::{Password, StoredCredential},
10+
hashcash::HashcashSolution,
11+
login5::{
12+
login_request::Login_method, ChallengeSolution, LoginError, LoginOk, LoginRequest,
13+
LoginResponse,
14+
},
15+
};
16+
use protobuf::well_known_types::duration::Duration as ProtoDuration;
17+
use protobuf::{Message, MessageField};
18+
use std::env::consts::OS;
19+
use std::time::{Duration, Instant};
20+
use thiserror::Error;
21+
use tokio::time::sleep;
22+
23+
const MAX_LOGIN_TRIES: u8 = 3;
24+
const LOGIN_TIMEOUT: Duration = Duration::from_secs(3);
25+
26+
component! {
27+
Login5Manager : Login5ManagerInner {
28+
auth_token: Option<Token> = None,
29+
}
30+
}
31+
32+
#[derive(Debug, Error)]
33+
enum Login5Error {
34+
#[error("Login request was denied: {0:?}")]
35+
FaultyRequest(LoginError),
36+
#[error("Code challenge is not supported")]
37+
CodeChallenge,
38+
#[error("Tried to acquire token without stored credentials")]
39+
NoStoredCredentials,
40+
#[error("Couldn't successfully authenticate after {0} times")]
41+
RetriesFailed(u8),
42+
#[error("Login via login5 is only allowed for android or ios")]
43+
OnlyForMobile,
44+
}
45+
46+
impl From<Login5Error> for Error {
47+
fn from(err: Login5Error) -> Self {
48+
match err {
49+
Login5Error::NoStoredCredentials | Login5Error::OnlyForMobile => {
50+
Error::unavailable(err)
51+
}
52+
Login5Error::RetriesFailed(_) | Login5Error::FaultyRequest(_) => {
53+
Error::failed_precondition(err)
54+
}
55+
Login5Error::CodeChallenge => Error::unimplemented(err),
56+
}
57+
}
58+
}
59+
60+
impl Login5Manager {
61+
async fn request(&self, message: &LoginRequest) -> Result<Bytes, Error> {
62+
let client_token = self.session().spclient().client_token().await?;
63+
let body = message.write_to_bytes()?;
64+
65+
let request = Request::builder()
66+
.method(&Method::POST)
67+
.uri("https://login5.spotify.com/v3/login")
68+
.header(ACCEPT, HeaderValue::from_static("application/x-protobuf"))
69+
.header(CLIENT_TOKEN, HeaderValue::from_str(&client_token)?)
70+
.body(body.into())?;
71+
72+
self.session().http_client().request_body(request).await
73+
}
74+
75+
async fn login5_request(&self, login: Login_method) -> Result<LoginOk, Error> {
76+
let client_id = match OS {
77+
"macos" | "windows" => self.session().client_id(),
78+
_ => SessionConfig::default().client_id,
79+
};
80+
81+
let mut login_request = LoginRequest {
82+
client_info: MessageField::some(ClientInfo {
83+
client_id,
84+
device_id: self.session().device_id().to_string(),
85+
special_fields: Default::default(),
86+
}),
87+
login_method: Some(login),
88+
..Default::default()
89+
};
90+
91+
let mut response = self.request(&login_request).await?;
92+
let mut count = 0;
93+
94+
loop {
95+
count += 1;
96+
97+
let message = LoginResponse::parse_from_bytes(&response)?;
98+
if let Some(Response::Ok(ok)) = message.response {
99+
break Ok(ok);
100+
}
101+
102+
if message.has_error() {
103+
match message.error() {
104+
LoginError::TIMEOUT | LoginError::TOO_MANY_ATTEMPTS => {
105+
sleep(LOGIN_TIMEOUT).await
106+
}
107+
others => return Err(Login5Error::FaultyRequest(others).into()),
108+
}
109+
}
110+
111+
if message.has_challenges() {
112+
// handles the challenges, and updates the login context with the response
113+
Self::handle_challenges(&mut login_request, message)?;
114+
}
115+
116+
if count < MAX_LOGIN_TRIES {
117+
response = self.request(&login_request).await?;
118+
} else {
119+
return Err(Login5Error::RetriesFailed(MAX_LOGIN_TRIES).into());
120+
}
121+
}
122+
}
123+
124+
/// Login for android and ios
125+
///
126+
/// This request doesn't require a connected session as it is the entrypoint for android or ios
127+
///
128+
/// This request will only work when:
129+
/// - client_id => android or ios | can be easily adjusted in [SessionConfig::default_for_os]
130+
/// - user-agent => android or ios | has to be adjusted in [HttpClient::new](crate::http_client::HttpClient::new)
131+
pub async fn login(
132+
&self,
133+
id: impl Into<String>,
134+
password: impl Into<String>,
135+
) -> Result<(Token, Vec<u8>), Error> {
136+
if !matches!(OS, "android" | "ios") {
137+
// by manipulating the user-agent and client-id it can be also used/tested on desktop
138+
return Err(Login5Error::OnlyForMobile.into());
139+
}
140+
141+
let method = Login_method::Password(Password {
142+
id: id.into(),
143+
password: password.into(),
144+
..Default::default()
145+
});
146+
147+
let token_response = self.login5_request(method).await?;
148+
let auth_token = Self::token_from_login(
149+
token_response.access_token,
150+
token_response.access_token_expires_in,
151+
);
152+
153+
Ok((auth_token, token_response.stored_credential))
154+
}
155+
156+
/// Retrieve the access_token via login5
157+
///
158+
/// This request will only work when the store credentials match the client-id. Meaning that
159+
/// stored credentials generated with the keymaster client-id will not work, for example, with
160+
/// the android client-id.
161+
pub async fn auth_token(&self) -> Result<Token, Error> {
162+
let auth_data = self.session().auth_data();
163+
if auth_data.is_empty() {
164+
return Err(Login5Error::NoStoredCredentials.into());
165+
}
166+
167+
let auth_token = self.lock(|inner| {
168+
if let Some(token) = &inner.auth_token {
169+
if token.is_expired() {
170+
inner.auth_token = None;
171+
}
172+
}
173+
inner.auth_token.clone()
174+
});
175+
176+
if let Some(auth_token) = auth_token {
177+
return Ok(auth_token);
178+
}
179+
180+
let method = Login_method::StoredCredential(StoredCredential {
181+
username: self.session().username().to_string(),
182+
data: auth_data,
183+
..Default::default()
184+
});
185+
186+
let token_response = self.login5_request(method).await?;
187+
let auth_token = Self::token_from_login(
188+
token_response.access_token,
189+
token_response.access_token_expires_in,
190+
);
191+
192+
let token = self.lock(|inner| {
193+
inner.auth_token = Some(auth_token.clone());
194+
inner.auth_token.clone()
195+
});
196+
197+
trace!("Got auth token: {:?}", auth_token);
198+
199+
token.ok_or(Login5Error::NoStoredCredentials.into())
200+
}
201+
202+
fn handle_challenges(
203+
login_request: &mut LoginRequest,
204+
message: LoginResponse,
205+
) -> Result<(), Error> {
206+
let challenges = message.challenges();
207+
debug!(
208+
"Received {} challenges, solving...",
209+
challenges.challenges.len()
210+
);
211+
212+
for challenge in &challenges.challenges {
213+
if challenge.has_code() {
214+
return Err(Login5Error::CodeChallenge.into());
215+
} else if !challenge.has_hashcash() {
216+
debug!("Challenge was empty, skipping...");
217+
continue;
218+
}
219+
220+
let hash_cash_challenge = challenge.hashcash();
221+
222+
let mut suffix = [0u8; 0x10];
223+
let duration = util::solve_hash_cash(
224+
&message.login_context,
225+
&hash_cash_challenge.prefix,
226+
hash_cash_challenge.length,
227+
&mut suffix,
228+
)?;
229+
230+
let (seconds, nanos) = (duration.as_secs() as i64, duration.subsec_nanos() as i32);
231+
debug!("Solving hashcash took {seconds}s {nanos}ns");
232+
233+
let mut solution = ChallengeSolution::new();
234+
solution.set_hashcash(HashcashSolution {
235+
suffix: Vec::from(suffix),
236+
duration: MessageField::some(ProtoDuration {
237+
seconds,
238+
nanos,
239+
..Default::default()
240+
}),
241+
..Default::default()
242+
});
243+
244+
login_request
245+
.challenge_solutions
246+
.mut_or_insert_default()
247+
.solutions
248+
.push(solution);
249+
}
250+
251+
login_request.login_context = message.login_context;
252+
253+
Ok(())
254+
}
255+
256+
fn token_from_login(token: String, expires_in: i32) -> Token {
257+
Token {
258+
access_token: token,
259+
expires_in: Duration::from_secs(expires_in.try_into().unwrap_or(3600)),
260+
token_type: "Bearer".to_string(),
261+
scopes: vec![],
262+
timestamp: Instant::now(),
263+
}
264+
}
265+
}

core/src/session.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ use crate::{
3535
config::SessionConfig,
3636
connection::{self, AuthenticationError, Transport},
3737
http_client::HttpClient,
38+
login5::Login5Manager,
3839
mercury::MercuryManager,
3940
packet::PacketType,
4041
protocol::keyexchange::ErrorCode,
@@ -101,6 +102,7 @@ struct SessionInternal {
101102
mercury: OnceCell<MercuryManager>,
102103
spclient: OnceCell<SpClient>,
103104
token_provider: OnceCell<TokenProvider>,
105+
login5: OnceCell<Login5Manager>,
104106
cache: Option<Arc<Cache>>,
105107

106108
handle: tokio::runtime::Handle,
@@ -141,6 +143,7 @@ impl Session {
141143
mercury: OnceCell::new(),
142144
spclient: OnceCell::new(),
143145
token_provider: OnceCell::new(),
146+
login5: OnceCell::new(),
144147
handle: tokio::runtime::Handle::current(),
145148
}))
146149
}
@@ -310,6 +313,12 @@ impl Session {
310313
.get_or_init(|| TokenProvider::new(self.weak()))
311314
}
312315

316+
pub fn login5(&self) -> &Login5Manager {
317+
self.0
318+
.login5
319+
.get_or_init(|| Login5Manager::new(self.weak()))
320+
}
321+
313322
pub fn time_delta(&self) -> i64 {
314323
self.0.data.read().time_delta
315324
}

0 commit comments

Comments
 (0)