Fix device login stalling silently until code expiry

Tidal edge WAF rejects HTTP Basic-Auth on /oauth2/token with a 403
HTML page, which the poll loop treated as an ordinary "not yet
authorized" response and retried with no logging, making a broken
login indistinguishable from a slow one until the code expired.

Send the client secret in the request body instead (also fixed for
token refresh), and classify poll outcomes explicitly so pending polls
are logged and real failures surface immediately with the actual
Tidal error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
AI User 2026-07-20 09:26:48 +02:00
parent 21e1b10c0b
commit 634b89b9e8
2 changed files with 118 additions and 59 deletions

View File

@ -577,40 +577,58 @@ impl Client {
#[instrument(skip(self))] #[instrument(skip(self))]
pub async fn login_web(&mut self) -> Result<(), ClientError> { pub async fn login_web(&mut self) -> Result<(), ClientError> {
let code_response = self.get_device_code().await?; 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 // The verification link must reach the user even without a log
// subscriber configured. // subscriber configured.
println!("https://{}", code_response.verification_uri_complete); println!("https://{}", code_response.verification_uri_complete);
info!( info!(
expires_in = code_response.expires_in,
interval = code_response.interval,
"waiting for device login at https://{}", "waiting for device login at https://{}",
code_response.verification_uri_complete code_response.verification_uri_complete
); );
while now.elapsed().as_secs() <= code_response.expires_in { // Poll no faster than the server asked for, and never busy-loop.
let login = self.check_auth_status(&code_response.device_code).await; let mut interval = code_response.interval.max(1);
if login.is_err() { while started.elapsed().as_secs() <= code_response.expires_in {
sleep(Duration::from_secs(code_response.interval)).await; match self.poll_device_token(&code_response.device_code).await {
continue; 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; sleep(Duration::from_secs(interval)).await;
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(());
} }
warn!("device login attempt expired"); warn!("device login attempt expired before it was authorized");
Err(ClientError::ConnectionError) Err(ClientError::AuthError("device login expired".to_string()))
} }
#[instrument(skip(self))] #[instrument(skip(self))]
@ -655,14 +673,11 @@ impl Client {
}; };
let body = serde_urlencoded::to_string(&data)?; let body = serde_urlencoded::to_string(&data)?;
// Secret in the body, not Basic auth — see [`Self::poll_device_token`].
let req = self let req = self
.http_client .http_client
.post("https://auth.tidal.com/v1/oauth2/token") .post(format!("{}/token", self.settings.oauth.base_url))
.body(body) .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") .header("Content-Type", "application/x-www-form-urlencoded")
.send() .send()
.await .await
@ -670,13 +685,16 @@ impl Client {
error!("{:?}", e); error!("{:?}", e);
e e
})?; })?;
if req.status().is_success() { let status = req.status();
if status.is_success() {
let res = req.json::<RefreshResponse>().await?; let res = req.json::<RefreshResponse>().await?;
Ok(res) Ok(res)
} else { } else {
Err(ClientError::AuthError( let body = req.text().await.unwrap_or_default();
"Failed to refresh access token".to_string(), let snippet: String = body.chars().take(300).collect();
)) Err(ClientError::AuthError(format!(
"token refresh returned {status}: {snippet}"
)))
} }
} }
#[instrument(skip(self))] #[instrument(skip(self))]
@ -709,51 +727,92 @@ impl Client {
Ok(code) Ok(code)
} }
#[instrument(skip(self))] /// One poll of the device-flow token endpoint.
pub async fn check_auth_status( ///
/// `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, &self,
device_code: &str, device_code: &str,
) -> Result<RefreshResponse, ClientError> { ) -> Result<Option<RefreshResponse>, PollError> {
let req = DeviceAuthRequest { let req = DeviceAuthRequest {
client_id: self.settings.oauth.client_id.clone(), 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()), device_code: Some(device_code.to_string()),
scope: Some("r_usr+w_usr+w_sub".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()), grant_type: Some("urn:ietf:params:oauth:grant-type:device_code".to_string()),
..Default::default() ..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 let res = self
.http_client .http_client
.post(format!("{}/token", self.settings.oauth.base_url)) .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) .body(payload)
.header("Content-Type", "application/x-www-form-urlencoded") .header("Content-Type", "application/x-www-form-urlencoded")
.send() .send()
.await .await
.map_err(|e| { .map_err(|err| PollError::Transport(err.into()))?;
error!("{:?}", e);
e let status = res.status();
})?; let body = res
if !res.status().is_success() { .text()
if res.status().is_client_error() { .await
return Err(ClientError::AuthError(format!( .map_err(|err| PollError::Transport(err.into()))?;
"Failed to check auth status: {}",
res.status().canonical_reason().unwrap_or("") if status.is_success() {
))); // NB: don't log the body here, it contains the tokens.
} else { return match serde_json::from_str::<RefreshResponse>(&body) {
return Err(ClientError::AuthError( Ok(refresh) => Ok(Some(refresh)),
"Failed to check auth status".to_string(), 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::<OauthErrorBody>(&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::<RefreshResponse>().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<String>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crabidy_core::ProviderClient; use crabidy_core::ProviderClient;

View File

@ -49,7 +49,7 @@ pub enum ClientError {
HttpClientError(#[from] reqwest::Error), HttpClientError(#[from] reqwest::Error),
#[error("internal serde url error")] #[error("internal serde url error")]
SerdeUrlError(#[from] serde_urlencoded::ser::Error), SerdeUrlError(#[from] serde_urlencoded::ser::Error),
#[error("authentication failed")] #[error("authentication failed: {0}")]
AuthError(String), AuthError(String),
#[error("tidal api returned status {0}")] #[error("tidal api returned status {0}")]
ApiError(u16), ApiError(u16),