diff --git a/tidaldy/src/lib.rs b/tidaldy/src/lib.rs index b0849d3..846bf10 100644 --- a/tidaldy/src/lib.rs +++ b/tidaldy/src/lib.rs @@ -577,40 +577,58 @@ impl Client { #[instrument(skip(self))] pub async fn login_web(&mut self) -> Result<(), ClientError> { let code_response = self.get_device_code().await?; - let now = Instant::now(); + let started = Instant::now(); // The verification link must reach the user even without a log // subscriber configured. println!("https://{}", code_response.verification_uri_complete); info!( + expires_in = code_response.expires_in, + interval = code_response.interval, "waiting for device login at https://{}", code_response.verification_uri_complete ); - while now.elapsed().as_secs() <= code_response.expires_in { - let login = self.check_auth_status(&code_response.device_code).await; - if login.is_err() { - sleep(Duration::from_secs(code_response.interval)).await; - continue; + // Poll no faster than the server asked for, and never busy-loop. + let mut interval = code_response.interval.max(1); + while started.elapsed().as_secs() <= code_response.expires_in { + match self.poll_device_token(&code_response.device_code).await { + Ok(Some(login_results)) => { + let timestamp = chrono::Utc::now().timestamp() as u64; + { + let mut login = match self.login.write() { + Ok(login) => login, + Err(poisoned) => poisoned.into_inner(), + }; + login.device_code = Some(code_response.device_code); + login.access_token = Some(login_results.access_token); + login.refresh_token = login_results.refresh_token; + login.expires_after = Some(login_results.expires_in + timestamp); + login.user_id = Some(login_results.user.user_id.to_string()); + login.country_code = Some(login_results.user.country_code); + } + info!("device login succeeded"); + return Ok(()); + } + Ok(None) => { + debug!(interval, "authorization pending"); + } + Err(PollError::SlowDown) => { + // Per RFC 8628 the client must back off by 5 seconds. + interval += 5; + debug!(interval, "server asked us to slow down"); + } + Err(PollError::Transport(err)) => { + // Transient network problems shouldn't kill the login. + warn!("device token poll failed, retrying: {err}"); + } + Err(PollError::Fatal(err)) => { + error!("device login failed: {err}"); + return Err(err); + } } - let timestamp = chrono::Utc::now().timestamp() as u64; - - let login_results = login?; - { - let mut login = match self.login.write() { - Ok(login) => login, - Err(poisoned) => poisoned.into_inner(), - }; - login.device_code = Some(code_response.device_code); - login.access_token = Some(login_results.access_token); - login.refresh_token = login_results.refresh_token; - login.expires_after = Some(login_results.expires_in + timestamp); - login.user_id = Some(login_results.user.user_id.to_string()); - login.country_code = Some(login_results.user.country_code); - } - info!("device login succeeded"); - return Ok(()); + sleep(Duration::from_secs(interval)).await; } - warn!("device login attempt expired"); - Err(ClientError::ConnectionError) + warn!("device login attempt expired before it was authorized"); + Err(ClientError::AuthError("device login expired".to_string())) } #[instrument(skip(self))] @@ -655,14 +673,11 @@ impl Client { }; let body = serde_urlencoded::to_string(&data)?; + // Secret in the body, not Basic auth — see [`Self::poll_device_token`]. let req = self .http_client - .post("https://auth.tidal.com/v1/oauth2/token") + .post(format!("{}/token", self.settings.oauth.base_url)) .body(body) - .basic_auth( - self.settings.oauth.client_id.clone(), - Some(self.settings.oauth.client_secret.clone()), - ) .header("Content-Type", "application/x-www-form-urlencoded") .send() .await @@ -670,13 +685,16 @@ impl Client { error!("{:?}", e); e })?; - if req.status().is_success() { + let status = req.status(); + if status.is_success() { let res = req.json::().await?; Ok(res) } else { - Err(ClientError::AuthError( - "Failed to refresh access token".to_string(), - )) + let body = req.text().await.unwrap_or_default(); + let snippet: String = body.chars().take(300).collect(); + Err(ClientError::AuthError(format!( + "token refresh returned {status}: {snippet}" + ))) } } #[instrument(skip(self))] @@ -709,51 +727,92 @@ impl Client { Ok(code) } - #[instrument(skip(self))] - pub async fn check_auth_status( + /// One poll of the device-flow token endpoint. + /// + /// `Ok(Some(_))` means the user authorized us, `Ok(None)` means the + /// authorization is still pending and the caller should keep polling. + #[instrument(skip(self, device_code))] + async fn poll_device_token( &self, device_code: &str, - ) -> Result { + ) -> Result, PollError> { let req = DeviceAuthRequest { client_id: self.settings.oauth.client_id.clone(), + // The secret must travel in the body: Tidal's edge rejects HTTP + // Basic auth on this endpoint with an HTML 403. + client_secret: Some(self.settings.oauth.client_secret.clone()), device_code: Some(device_code.to_string()), scope: Some("r_usr+w_usr+w_sub".to_string()), grant_type: Some("urn:ietf:params:oauth:grant-type:device_code".to_string()), ..Default::default() }; - let payload = serde_urlencoded::to_string(&req)?; + let payload = + serde_urlencoded::to_string(&req).map_err(|err| PollError::Fatal(err.into()))?; let res = self .http_client .post(format!("{}/token", self.settings.oauth.base_url)) - .basic_auth( - self.settings.oauth.client_id.clone(), - Some(self.settings.oauth.client_secret.clone()), - ) .body(payload) .header("Content-Type", "application/x-www-form-urlencoded") .send() .await - .map_err(|e| { - error!("{:?}", e); - e - })?; - if !res.status().is_success() { - if res.status().is_client_error() { - return Err(ClientError::AuthError(format!( - "Failed to check auth status: {}", - res.status().canonical_reason().unwrap_or("") - ))); - } else { - return Err(ClientError::AuthError( - "Failed to check auth status".to_string(), - )); + .map_err(|err| PollError::Transport(err.into()))?; + + let status = res.status(); + let body = res + .text() + .await + .map_err(|err| PollError::Transport(err.into()))?; + + if status.is_success() { + // NB: don't log the body here, it contains the tokens. + return match serde_json::from_str::(&body) { + Ok(refresh) => Ok(Some(refresh)), + Err(err) => Err(PollError::Fatal(ClientError::AuthError(format!( + "could not decode token response: {err}" + )))), + }; + } + + // OAuth error responses carry the reason in an `error` field + // (RFC 8628 §3.5); Tidal additionally sets `sub_status`. + let oauth_error = serde_json::from_str::(&body).unwrap_or_default(); + match oauth_error.error.as_deref() { + Some("authorization_pending") => Ok(None), + Some("slow_down") => Err(PollError::SlowDown), + _ if status.is_server_error() => { + Err(PollError::Transport(ClientError::ApiError(status.as_u16()))) + } + _ => { + // Error bodies carry no secrets, so quoting them is safe + // and beats guessing why the flow died. + let body_snippet: String = body.chars().take(300).collect(); + Err(PollError::Fatal(ClientError::AuthError(format!( + "token endpoint returned {status}: {body_snippet}" + )))) } } - let refresh = res.json::().await?; - Ok(refresh) } } +/// Outcome classification for one device-flow token poll. +#[derive(Debug)] +enum PollError { + /// The server asked us to poll less often (RFC 8628 `slow_down`). + SlowDown, + /// A transient failure; keep polling. + Transport(ClientError), + /// The flow cannot succeed anymore; stop polling. + Fatal(ClientError), +} + +/// Lenient shape of an OAuth token-endpoint error body: only `error` is +/// used to classify the failure; the full body is logged separately for +/// anything not otherwise handled. +#[derive(Debug, Default, serde::Deserialize)] +struct OauthErrorBody { + error: Option, +} + #[cfg(test)] mod tests { use crabidy_core::ProviderClient; diff --git a/tidaldy/src/models.rs b/tidaldy/src/models.rs index 930c2e8..351c312 100644 --- a/tidaldy/src/models.rs +++ b/tidaldy/src/models.rs @@ -49,7 +49,7 @@ pub enum ClientError { HttpClientError(#[from] reqwest::Error), #[error("internal serde url error")] SerdeUrlError(#[from] serde_urlencoded::ser::Error), - #[error("authentication failed")] + #[error("authentication failed: {0}")] AuthError(String), #[error("tidal api returned status {0}")] ApiError(u16),