librespot_core/
login5.rs

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            // StoredCredential is used to get an access_token from Session credentials.
79            // Using the session client_id allows user to use Keymaster on Android/IOS
80            // if their Credentials::with_access_token was obtained there, assuming
81            // they have overriden the SessionConfig::client_id with the Keymaster's.
82            _ 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                // handles the challenges, and updates the login context with the response
118                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    /// Login for android and ios
130    ///
131    /// This request doesn't require a connected session as it is the entrypoint for android or ios
132    ///
133    /// This request will only work when:
134    /// - client_id => android or ios | can be easily adjusted in [SessionConfig::default_for_os]
135    /// - user-agent => android or ios | has to be adjusted in [HttpClient::new](crate::http_client::HttpClient::new)
136    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            // by manipulating the user-agent and client-id it can be also used/tested on desktop
143            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    /// Retrieve the access_token via login5
162    ///
163    /// This request will only work when the store credentials match the client-id. Meaning that
164    /// stored credentials generated with the keymaster client-id will not work, for example, with
165    /// the android client-id.
166    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}