use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::api_key::ApiKey; use crate::pkce::PkceSession; const TOKEN_URL: &str = "https://auth.tidal.com/v1/oauth2/token"; #[derive(Debug, Clone, Default)] pub struct PendingPkce { pub verifier: String, pub redirect_uri: String, pub client_unique_key: String, } impl PendingPkce { pub fn from_session(session: &PkceSession, redirect_uri: &str) -> Self { Self { verifier: session.verifier.clone(), redirect_uri: redirect_uri.to_string(), client_unique_key: session.client_unique_key.clone(), } } } #[derive(Debug, Clone, Deserialize)] struct TokenResponse { access_token: String, refresh_token: Option, expires_in: Option, user: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct TokenUser { user_id: u64, country_code: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Session { pub user_id: u64, pub country_code: String, pub access_token: String, pub refresh_token: Option, pub expires_in: i64, } pub async fn exchange_code( client: &reqwest::Client, key: &ApiKey, pending: &PendingPkce, code: &str, ) -> Result { let redirect_uri = key.redirect_uri.clone(); let form = [ ("client_id", key.client_id.as_str()), ("client_unique_key", pending.client_unique_key.as_str()), ("code", code), ("code_verifier", pending.verifier.as_str()), ("grant_type", "authorization_code"), ("redirect_uri", redirect_uri.as_str()), ("scope", key.scope.as_str()), ]; let resp: serde_json::Value = client .post(TOKEN_URL) .form(&form) .send() .await .context("token exchange request")? .json() .await .context("token exchange body")?; if resp.get("access_token").is_none() { anyhow::bail!("Get token failed: {resp}"); } let token: TokenResponse = serde_json::from_value(resp).context("token response")?; Ok(Session { user_id: token.user.as_ref().map(|u| u.user_id).unwrap_or(0), country_code: token.user.map(|u| u.country_code).unwrap_or_default(), access_token: token.access_token, refresh_token: token.refresh_token, expires_in: token.expires_in.unwrap_or(0), }) } pub async fn verify_access_token(client: &reqwest::Client, access_token: &str) -> bool { let resp = client .get("https://api.tidal.com/v1/sessions") .bearer_auth(access_token) .send() .await; match resp { Ok(r) => r.status().is_success(), Err(_) => false, } } pub async fn refresh_token( client: &reqwest::Client, key: &ApiKey, refresh_token: &str, ) -> Result { let form = [ ("client_id", key.client_id.as_str()), ("refresh_token", refresh_token), ("grant_type", "refresh_token"), ("scope", key.scope.as_str()), ]; let mut request = client.post(TOKEN_URL); if !key.client_secret.is_empty() { request = request.basic_auth(&key.client_id, Some(&key.client_secret)); } let resp: serde_json::Value = request .form(&form) .send() .await .context("refresh request")? .json() .await .context("refresh body")?; if resp.get("access_token").is_none() { anyhow::bail!("Refresh failed: {resp}"); } let token: TokenResponse = serde_json::from_value(resp).context("refresh response")?; Ok(Session { user_id: token.user.as_ref().map(|u| u.user_id).unwrap_or(0), country_code: token.user.map(|u| u.country_code).unwrap_or_default(), access_token: token.access_token, refresh_token: token.refresh_token.or(Some(refresh_token.to_string())), expires_in: token.expires_in.unwrap_or(0), }) } const DEVICE_AUTH_URL: &str = "https://auth.tidal.com/v1/oauth2/device_authorization"; pub const DEVICE_SCOPE: &str = "r_usr+w_usr+w_sub"; #[derive(Debug, Clone, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeviceCode { pub device_code: String, pub user_code: String, pub verification_uri: String, pub expires_in: u64, pub interval: u64, } pub async fn start_device_code( client: &reqwest::Client, key: &ApiKey, ) -> Result { let resp: serde_json::Value = client .post(DEVICE_AUTH_URL) .form(&[("client_id", key.client_id.as_str()), ("scope", DEVICE_SCOPE)]) .send() .await .context("device authorization request")? .json() .await .context("device authorization body")?; if let Some(status) = resp.get("status") { anyhow::bail!("device authorization failed (status {status}) — try another key"); } serde_json::from_value(resp).context("device authorization response") } /// Map one device-poll response: `Ok(None)` means still waiting. pub fn device_poll_result(resp: serde_json::Value) -> Result> { if resp.get("access_token").is_some() { let token: TokenResponse = serde_json::from_value(resp).context("device token response")?; return Ok(Some(Session { user_id: token.user.as_ref().map(|u| u.user_id).unwrap_or(0), country_code: token.user.map(|u| u.country_code).unwrap_or_default(), access_token: token.access_token, refresh_token: token.refresh_token, expires_in: token.expires_in.unwrap_or(0), })); } let status = resp.get("status").and_then(|s| s.as_u64()).unwrap_or(0); let sub = resp.get("sub_status").and_then(|s| s.as_u64()).unwrap_or(0); if status == 400 && sub == 1002 { return Ok(None); } anyhow::bail!("device authorization failed: {resp}"); } pub async fn poll_device( client: &reqwest::Client, key: &ApiKey, device_code: &str, ) -> Result> { let resp: serde_json::Value = client .post(TOKEN_URL) .basic_auth(&key.client_id, Some(&key.client_secret)) .form(&[ ("client_id", key.client_id.as_str()), ("device_code", device_code), ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("scope", DEVICE_SCOPE), ]) .send() .await .context("device poll request")? .json() .await .context("device poll body")?; device_poll_result(resp) } #[cfg(test)] mod tests { use super::*; #[test] fn device_poll_maps_pending_done_and_error() { let pending = serde_json::json!({"status": 400, "sub_status": 1002}); assert!(device_poll_result(pending).unwrap().is_none()); let done = serde_json::json!({ "access_token": "a", "refresh_token": "r", "expires_in": 3600, "user": {"userId": 7, "countryCode": "US"}, }); let sess = device_poll_result(done).unwrap().unwrap(); assert_eq!(sess.user_id, 7); let err = serde_json::json!({"status": 400, "sub_status": 1005}); assert!(device_poll_result(err).is_err()); } }