1use crate::config::OS;
2use crate::spclient::CLIENT_TOKEN;
3use crate::token::Token;
4use crate::{Error, SessionConfig, util};
5use bytes::Bytes;
6use http::{HeaderValue, Method, Request, header::ACCEPT};
7use librespot_protocol::login5::login_response::Response;
8use librespot_protocol::{
9 client_info::ClientInfo,
10 credentials::{Password, StoredCredential},
11 hashcash::HashcashSolution,
12 login5::{
13 ChallengeSolution, LoginError, LoginOk, LoginRequest, LoginResponse,
14 login_request::Login_method,
15 },
16};
17use protobuf::well_known_types::duration::Duration as ProtoDuration;
18use protobuf::{Message, MessageField};
19use std::time::{Duration, SystemTime};
20use thiserror::Error;
21use tokio::time::sleep;
22
23const MAX_LOGIN_TRIES: u8 = 3;
24const LOGIN_TIMEOUT: Duration = Duration::from_secs(3);
25
26component! {
27 Login5Manager : Login5ManagerInner {
28 auth_token: Option<Token> = None,
29 }
30}
31
32#[derive(Debug, Error)]
33enum 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
46impl 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
60impl 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 _ if matches!(login, Login_method::StoredCredential(_)) => self.session().client_id(),
83 _ => SessionConfig::default().client_id,
84 };
85
86 let mut login_request = LoginRequest {
87 client_info: MessageField::some(ClientInfo {
88 client_id,
89 device_id: self.session().device_id().to_string(),
90 special_fields: Default::default(),
91 }),
92 login_method: Some(login),
93 ..Default::default()
94 };
95
96 let mut response = self.request(&login_request).await?;
97 let mut count = 0;
98
99 loop {
100 count += 1;
101
102 let message = LoginResponse::parse_from_bytes(&response)?;
103 if let Some(Response::Ok(ok)) = message.response {
104 break Ok(ok);
105 }
106
107 if message.has_error() {
108 match message.error() {
109 LoginError::TIMEOUT | LoginError::TOO_MANY_ATTEMPTS => {
110 sleep(LOGIN_TIMEOUT).await
111 }
112 others => return Err(Login5Error::FaultyRequest(others).into()),
113 }
114 }
115
116 if message.has_challenges() {
117 Self::handle_challenges(&mut login_request, message)?;
119 }
120
121 if count < MAX_LOGIN_TRIES {
122 response = self.request(&login_request).await?;
123 } else {
124 return Err(Login5Error::RetriesFailed(MAX_LOGIN_TRIES).into());
125 }
126 }
127 }
128
129 pub async fn login(
137 &self,
138 id: impl Into<String>,
139 password: impl Into<String>,
140 ) -> Result<(Token, Vec<u8>), Error> {
141 if !matches!(OS, "android" | "ios") {
142 return Err(Login5Error::OnlyForMobile.into());
144 }
145
146 let method = Login_method::Password(Password {
147 id: id.into(),
148 password: password.into(),
149 ..Default::default()
150 });
151
152 let token_response = self.login5_request(method).await?;
153 let auth_token = Self::token_from_login(
154 token_response.access_token,
155 token_response.access_token_expires_in,
156 );
157
158 Ok((auth_token, token_response.stored_credential))
159 }
160
161 pub async fn auth_token(&self) -> Result<Token, Error> {
167 let auth_data = self.session().auth_data();
168 if auth_data.is_empty() {
169 return Err(Login5Error::NoStoredCredentials.into());
170 }
171
172 let auth_token = self.lock(|inner| {
173 if let Some(token) = &inner.auth_token {
174 if token.is_expired() {
175 inner.auth_token = None;
176 }
177 }
178 inner.auth_token.clone()
179 });
180
181 if let Some(auth_token) = auth_token {
182 return Ok(auth_token);
183 }
184
185 let method = Login_method::StoredCredential(StoredCredential {
186 username: self.session().username().to_string(),
187 data: auth_data,
188 ..Default::default()
189 });
190
191 let token_response = self.login5_request(method).await?;
192 let auth_token = Self::token_from_login(
193 token_response.access_token,
194 token_response.access_token_expires_in,
195 );
196
197 let token = self.lock(|inner| {
198 inner.auth_token = Some(auth_token.clone());
199 inner.auth_token.clone()
200 });
201
202 trace!("Got auth token: {auth_token:?}");
203
204 token.ok_or(Login5Error::NoStoredCredentials.into())
205 }
206
207 fn handle_challenges(
208 login_request: &mut LoginRequest,
209 message: LoginResponse,
210 ) -> Result<(), Error> {
211 let challenges = message.challenges();
212 debug!(
213 "Received {} challenges, solving...",
214 challenges.challenges.len()
215 );
216
217 for challenge in &challenges.challenges {
218 if challenge.has_code() {
219 return Err(Login5Error::CodeChallenge.into());
220 } else if !challenge.has_hashcash() {
221 debug!("Challenge was empty, skipping...");
222 continue;
223 }
224
225 let hash_cash_challenge = challenge.hashcash();
226
227 let mut suffix = [0u8; 0x10];
228 let duration = util::solve_hash_cash(
229 &message.login_context,
230 &hash_cash_challenge.prefix,
231 hash_cash_challenge.length,
232 &mut suffix,
233 )?;
234
235 let (seconds, nanos) = (duration.as_secs() as i64, duration.subsec_nanos() as i32);
236 debug!("Solving hashcash took {seconds}s {nanos}ns");
237
238 let mut solution = ChallengeSolution::new();
239 solution.set_hashcash(HashcashSolution {
240 suffix: Vec::from(suffix),
241 duration: MessageField::some(ProtoDuration {
242 seconds,
243 nanos,
244 ..Default::default()
245 }),
246 ..Default::default()
247 });
248
249 login_request
250 .challenge_solutions
251 .mut_or_insert_default()
252 .solutions
253 .push(solution);
254 }
255
256 login_request.login_context = message.login_context;
257
258 Ok(())
259 }
260
261 fn token_from_login(token: String, expires_in: i32) -> Token {
262 Token {
263 access_token: token,
264 expires_in: Duration::from_secs(expires_in.try_into().unwrap_or(3600)),
265 token_type: "Bearer".to_string(),
266 scopes: vec![],
267 timestamp: SystemTime::now(),
268 }
269 }
270}