Skip to main content

tidal_player_api/auth/
device.rs

1//! The OAuth2 device flow (spec 0002, AC1–AC3).
2
3use std::fmt;
4use std::sync::Arc;
5use std::time::Duration;
6
7use serde::Deserialize;
8
9use super::{
10    AuthConfig, AuthError, Clock, DEVICE_CODE_GRANT, LOSSY_WARNING, PollOutcome, RefreshClient,
11    SCOPE, Session, SessionStore, Sleeper, SystemClock, TokenGrant, classify_poll, error_code,
12    form_body, post_refresh, session_from_login, session_from_refresh,
13};
14
15/// Tidal's answer to `POST /device_authorization`: what the user must open
16/// and type, and how to poll. Its `Debug` redacts `device_code`, which can be
17/// exchanged for tokens until it expires.
18#[derive(Clone, PartialEq, Eq)]
19pub struct DeviceCode {
20    pub device_code: String,
21    /// The code the user types on the Tidal page (5 characters).
22    pub user_code: String,
23    /// Without a scheme, e.g. `link.tidal.com`.
24    pub verification_uri: String,
25    /// The URL with the code, e.g. `link.tidal.com/ABCDE`, when sent.
26    pub verification_uri_complete: Option<String>,
27    /// How long the code is valid (300 s on the live API).
28    pub expires_in: Duration,
29    /// Minimum wait between polls.
30    pub interval: Duration,
31}
32
33impl fmt::Debug for DeviceCode {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        f.debug_struct("DeviceCode")
36            .field("device_code", &"<redacted>")
37            .field("user_code", &self.user_code)
38            .field("verification_uri", &self.verification_uri)
39            .field("verification_uri_complete", &self.verification_uri_complete)
40            .field("expires_in", &self.expires_in)
41            .field("interval", &self.interval)
42            .finish()
43    }
44}
45
46/// `device_authorization`'s camelCase body.
47#[derive(Deserialize)]
48#[serde(rename_all = "camelCase")]
49struct RawDeviceCode {
50    device_code: String,
51    user_code: String,
52    verification_uri: String,
53    #[serde(default)]
54    verification_uri_complete: Option<String>,
55    expires_in: u64,
56    interval: u64,
57}
58
59impl DeviceCode {
60    /// Parses Tidal's camelCase `device_authorization` body.
61    pub fn from_json(body: &str) -> Result<Self, AuthError> {
62        let raw: RawDeviceCode =
63            serde_json::from_str(body).map_err(|_| AuthError::Decode("device authorization"))?;
64        Ok(Self {
65            device_code: raw.device_code,
66            user_code: raw.user_code,
67            verification_uri: raw.verification_uri,
68            verification_uri_complete: raw.verification_uri_complete,
69            expires_in: Duration::from_secs(raw.expires_in),
70            interval: Duration::from_secs(raw.interval),
71        })
72    }
73
74    /// The link printed for the user: `https://` + `verificationUri`.
75    pub fn login_link(&self) -> String {
76        https(&self.verification_uri)
77    }
78
79    /// The link opened in a browser: `https://` + `verificationUriComplete`
80    /// when sent, else [`login_link`](Self::login_link).
81    pub fn browser_link(&self) -> String {
82        match &self.verification_uri_complete {
83            Some(complete) => https(complete),
84            None => self.login_link(),
85        }
86    }
87}
88
89/// Prepends `https://` unless `uri` already has a scheme.
90fn https(uri: &str) -> String {
91    if uri.starts_with("https://") || uri.starts_with("http://") {
92        uri.to_owned()
93    } else {
94        format!("https://{uri}")
95    }
96}
97
98/// What [`DeviceFlow::complete_login`] stored.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Login {
101    pub session: Session,
102    /// The client `session`'s access token belongs to:
103    /// [`RefreshClient::DeviceFlow`] when the post-login refresh under the
104    /// PKCE client failed and the grant was stored as is (AC19).
105    pub client: RefreshClient,
106}
107
108/// Runs the device flow: [`start_device_flow`](Self::start_device_flow),
109/// show the code, then [`complete_login`](Self::complete_login).
110pub struct DeviceFlow {
111    http: reqwest::Client,
112    config: AuthConfig,
113    clock: Arc<dyn Clock>,
114    sleeper: Arc<dyn Sleeper>,
115}
116
117impl fmt::Debug for DeviceFlow {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        f.debug_struct("DeviceFlow")
120            .field("config", &self.config)
121            .finish_non_exhaustive()
122    }
123}
124
125impl DeviceFlow {
126    /// A flow on the real clock.
127    pub fn new(config: AuthConfig) -> Result<Self, AuthError> {
128        Self::with_time(config, Arc::new(SystemClock), Arc::new(SystemClock))
129    }
130
131    /// A flow with an injected clock and sleep (tests).
132    pub fn with_time(
133        config: AuthConfig,
134        clock: Arc<dyn Clock>,
135        sleeper: Arc<dyn Sleeper>,
136    ) -> Result<Self, AuthError> {
137        let http = reqwest::Client::builder()
138            .build()
139            .map_err(|e| AuthError::transport(&e))?;
140        Ok(Self {
141            http,
142            config,
143            clock,
144            sleeper,
145        })
146    }
147
148    /// `POST {auth_base}/device_authorization` (AC1).
149    pub async fn start_device_flow(&self) -> Result<DeviceCode, AuthError> {
150        let response = self
151            .http
152            .post(self.config.auth_url("device_authorization"))
153            .header("content-type", "application/x-www-form-urlencoded")
154            .body(form_body(&[
155                ("client_id", &self.config.client_id),
156                ("scope", SCOPE),
157            ]))
158            .send()
159            .await
160            .map_err(|e| AuthError::transport(&e))?;
161        let status = response.status();
162        let body = response
163            .text()
164            .await
165            .map_err(|e| AuthError::transport(&e))?;
166        if !status.is_success() {
167            return Err(AuthError::Http {
168                status: status.as_u16(),
169                code: error_code(&body).map(Into::into),
170            });
171        }
172        DeviceCode::from_json(&body)
173    }
174
175    /// One `POST {auth_base}/token` for `code` (AC2). A transport error is
176    /// "not yet" (the live API dropped one poll, see the spec's edge cases).
177    pub async fn poll(
178        &self,
179        code: &DeviceCode,
180        interval: Duration,
181    ) -> Result<PollOutcome, AuthError> {
182        let response = self
183            .http
184            .post(self.config.auth_url("token"))
185            .basic_auth(&self.config.client_id, Some(&self.config.client_secret))
186            .header("content-type", "application/x-www-form-urlencoded")
187            .body(form_body(&[
188                ("client_id", &self.config.client_id),
189                ("grant_type", DEVICE_CODE_GRANT),
190                ("device_code", &code.device_code),
191                ("scope", SCOPE),
192            ]))
193            .send()
194            .await;
195        let response = match response {
196            Ok(response) => response,
197            Err(error) => {
198                tracing::debug!(%error, "device-code poll got no response; polling on");
199                return Ok(PollOutcome::Pending { interval });
200            }
201        };
202        let status = response.status().as_u16();
203        let body = match response.text().await {
204            Ok(body) => body,
205            Err(_) => return Ok(PollOutcome::Pending { interval }),
206        };
207        classify_poll(status, &body, interval)
208    }
209
210    /// Polls until the user approves, the code expires or is denied (AC2),
211    /// and builds the session from the token response (AC3).
212    pub async fn wait_for_session(&self, code: &DeviceCode) -> Result<Session, AuthError> {
213        let deadline = self.clock.now() + code.expires_in;
214        let mut interval = code.interval;
215        loop {
216            match self.poll(code, interval).await? {
217                PollOutcome::Granted(grant) => {
218                    return session_from_login(&grant, self.clock.now());
219                }
220                PollOutcome::Pending { interval: next } => interval = next,
221            }
222            if self.clock.now() >= deadline {
223                return Err(AuthError::CodeExpired);
224            }
225            self.sleeper.sleep(interval).await;
226        }
227    }
228
229    /// [`wait_for_session`](Self::wait_for_session), one refresh under the
230    /// PKCE client (AC19), then saves the session to `store`. When that
231    /// refresh fails for any reason, the grant's session is saved as is.
232    /// Nothing is stored when the login fails.
233    pub async fn complete_login(
234        &self,
235        code: &DeviceCode,
236        store: &dyn SessionStore,
237    ) -> Result<Login, AuthError> {
238        let granted = self.wait_for_session(code).await?;
239        let login = match self.refresh_under_pkce(&granted).await {
240            Ok(session) => Login {
241                session,
242                client: RefreshClient::Pkce,
243            },
244            Err(error) => {
245                tracing::warn!(%error, "{LOSSY_WARNING}");
246                Login {
247                    session: granted,
248                    client: RefreshClient::DeviceFlow,
249                }
250            }
251        };
252        store.save(&login.session)?;
253        Ok(login)
254    }
255
256    /// `granted` refreshed under the PKCE client, without the fallback: the
257    /// grant already is the device-flow client's.
258    async fn refresh_under_pkce(&self, granted: &Session) -> Result<Session, AuthError> {
259        let (status, body) = post_refresh(
260            &self.http,
261            &self.config,
262            RefreshClient::Pkce,
263            &granted.refresh_token,
264        )
265        .await?;
266        if !(200..300).contains(&status) {
267            return Err(AuthError::Http {
268                status,
269                code: error_code(&body).map(Into::into),
270            });
271        }
272        let grant = TokenGrant::from_json(&body)?;
273        Ok(session_from_refresh(granted, &grant, self.clock.now()))
274    }
275}