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,23 +577,22 @@ 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 login_results = login?;
{
let mut login = match self.login.write() {
Ok(login) => login,
@ -609,8 +608,27 @@ impl Client {
info!("device login succeeded");
return Ok(());
}
warn!("device login attempt expired");
Err(ClientError::ConnectionError)
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);
}
}
sleep(Duration::from_secs(interval)).await;
}
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::<RefreshResponse>().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<RefreshResponse, ClientError> {
) -> Result<Option<RefreshResponse>, 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::<RefreshResponse>(&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::<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)]
mod tests {
use crabidy_core::ProviderClient;

View File

@ -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),