Skip to main content

tidal_player_api/
auth.rs

1//! Tidal authentication. See docs/specs/0002-auth.md.
2//!
3//! - [`Session`] and the [`SessionStore`] seam the binary implements
4//!   (keyring, encrypted file)
5//! - the OAuth2 device flow: [`DeviceFlow`] (AC1–AC3)
6//! - [`Authenticator`]: owns the in-memory session, sends authenticated
7//!   requests, refreshes (single flight, persisted) and recovers (AC4–AC9)
8//! - the pure classifiers the above are built on ([`classify_poll`],
9//!   [`classify_refresh_failure`], [`needs_refresh`])
10//!
11//! Tokens never appear in `Debug` output or error messages (AC15).
12
13mod authenticator;
14mod device;
15
16use std::fmt;
17use std::future::Future;
18use std::pin::Pin;
19use std::sync::Mutex;
20use std::time::{Duration, SystemTime};
21
22use serde::{Deserialize, Serialize};
23
24pub use authenticator::{AuthStatus, Authenticator};
25pub use device::{DeviceCode, DeviceFlow, Login};
26
27/// Production base URL of Tidal's OAuth2 endpoints.
28pub const AUTH_BASE: &str = "https://auth.tidal.com/v1/oauth2";
29/// Production base URL of the Tidal v1 API.
30pub const API_BASE: &str = "https://api.tidal.com/v1";
31/// Public client ID of the official Tidal app (spec 0002, decision 2).
32pub const CLIENT_ID: &str = "fX2JxdmntZWK0ixT";
33/// Public client secret of the official Tidal app (spec 0002, decision 2).
34pub const CLIENT_SECRET: &str = "1Nn9AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=";
35/// Public client ID of the official Android app's PKCE login, used for every
36/// refresh: tokens of [`CLIENT_ID`] get AAC for 16-bit tracks (AC19). The
37/// `client_id_pkce` that `tidalapi` ships (spec 0002, decision 2).
38pub const PKCE_CLIENT_ID: &str = "6BDSRdpK9hqEBTgU";
39/// Public client secret that goes with [`PKCE_CLIENT_ID`] (`tidalapi`'s
40/// `client_secret_pkce`).
41pub const PKCE_CLIENT_SECRET: &str = "xeuPmY7nbpZ9IIbLAcQ93shka1VNheUAqN6IcszjTG8=";
42/// OAuth2 scopes requested at login.
43pub const SCOPE: &str = "r_usr w_usr w_sub";
44
45/// Grant type of a device-code poll (RFC 8628).
46pub const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
47/// A token that expires within this margin is refreshed before use (AC4).
48pub const REFRESH_MARGIN: Duration = Duration::from_secs(60);
49/// Added to the poll interval on `slow_down` (AC2).
50pub const SLOW_DOWN_STEP: Duration = Duration::from_secs(5);
51/// In the "session lost" state, storage is re-read at most this often (AC9).
52pub const LOST_RECHECK: Duration = Duration::from_secs(5);
53
54/// A logged-in Tidal session, as stored between runs.
55#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Session {
57    pub access_token: String,
58    pub refresh_token: String,
59    /// Absolute expiry of `access_token`, computed at receipt from `expires_in`.
60    pub expires_at: SystemTime,
61    pub user_id: u64,
62    pub country_code: String,
63}
64
65/// Redacts both tokens (AC15).
66impl fmt::Debug for Session {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.debug_struct("Session")
69            .field("access_token", &"<redacted>")
70            .field("refresh_token", &"<redacted>")
71            .field("expires_at", &self.expires_at)
72            .field("user_id", &self.user_id)
73            .field("country_code", &self.country_code)
74            .finish()
75    }
76}
77
78/// Errors from a [`SessionStore`].
79#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
80pub enum StoreError {
81    /// The backend cannot be used at all here (no Secret Service, no
82    /// passphrase source, ...); a fallback store may take over.
83    #[error("session store unavailable: {0}")]
84    Unavailable(String),
85    /// The encrypted session file exists but the passphrase does not open it.
86    #[error("wrong passphrase for the session file")]
87    WrongPassphrase,
88    /// Any other failure (I/O, encoding); never contains a token.
89    #[error("session store error: {0}")]
90    Other(String),
91}
92
93/// Where the session lives between runs. Implementations block briefly
94/// (keyring call, small file), so they are synchronous.
95pub trait SessionStore: Send + Sync {
96    /// The stored session, or `None` when not logged in (missing, unreadable
97    /// or unknown-version data).
98    fn load(&self) -> Result<Option<Session>, StoreError>;
99    /// Replaces the stored session.
100    fn save(&self, session: &Session) -> Result<(), StoreError>;
101    /// Removes the stored session; succeeds when there is none.
102    fn delete(&self) -> Result<(), StoreError>;
103}
104
105/// In-memory [`SessionStore`], for tests and as a building block.
106#[derive(Debug, Default)]
107pub struct MemoryStore {
108    session: Mutex<Option<Session>>,
109}
110
111impl MemoryStore {
112    /// A store that already holds `session`.
113    pub fn with_session(session: Session) -> Self {
114        Self {
115            session: Mutex::new(Some(session)),
116        }
117    }
118}
119
120impl SessionStore for MemoryStore {
121    fn load(&self) -> Result<Option<Session>, StoreError> {
122        Ok(self.session.lock().expect("poisoned").clone())
123    }
124
125    fn save(&self, session: &Session) -> Result<(), StoreError> {
126        *self.session.lock().expect("poisoned") = Some(session.clone());
127        Ok(())
128    }
129
130    fn delete(&self) -> Result<(), StoreError> {
131        *self.session.lock().expect("poisoned") = None;
132        Ok(())
133    }
134}
135
136/// Errors from the device flow and the [`Authenticator`]. No variant ever
137/// carries a token or a server body (AC15).
138#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
139pub enum AuthError {
140    /// No HTTP response (connection, timeout, TLS). Transient.
141    #[error("network error talking to Tidal: {0}")]
142    Transport(String),
143    /// An HTTP status the caller does not handle. `code` is the OAuth2
144    /// `error` code, kept only when it is one of the known codes. A `5xx`
145    /// or `429` from a refresh is transient.
146    #[error("unexpected HTTP status {status}{}", code.as_deref().map(|c| format!(" ({c})")).unwrap_or_default())]
147    Http { status: u16, code: Option<String> },
148    /// A `2xx` body that could not be decoded; names what was being read.
149    #[error("malformed {0} response from Tidal")]
150    Decode(&'static str),
151    /// The device code expired before the user approved it (AC2).
152    #[error("Login code expired, run \"tidal-player login\" again")]
153    CodeExpired,
154    /// The user denied the login on the Tidal page (AC2).
155    #[error("Login was denied")]
156    Denied,
157    /// The token response lacks the user ID or country (AC3); names which.
158    #[error("Tidal's login response has no {0}")]
159    SessionInfo(&'static str),
160    /// The session is lost (refresh token rejected): run `tidal-player login`.
161    #[error("Session expired: run \"tidal-player login\"")]
162    LoginRequired,
163    /// A request was rejected with `401` again right after a refresh (AC5).
164    #[error("Tidal rejected the access token (HTTP 401) after a refresh")]
165    Unauthorized,
166    /// The session store failed.
167    #[error(transparent)]
168    Store(#[from] StoreError),
169}
170
171impl AuthError {
172    /// Whether a later attempt may succeed without a new login: network
173    /// errors, `5xx` and `429`.
174    pub fn is_transient(&self) -> bool {
175        match self {
176            Self::Transport(_) => true,
177            Self::Http { status, .. } => *status == 429 || *status >= 500,
178            _ => false,
179        }
180    }
181
182    /// Maps a transport-level failure; the message never contains a token
183    /// (tokens travel in headers and form bodies, never in URLs).
184    pub(crate) fn transport(error: &reqwest::Error) -> Self {
185        Self::Transport(error.to_string())
186    }
187}
188
189/// Source of the current time, injected so tests never wait.
190pub trait Clock: Send + Sync {
191    fn now(&self) -> SystemTime;
192}
193
194/// A boxed `Send` future, as returned by [`Sleeper::sleep`].
195pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
196
197/// Waits between device-flow polls, injected so tests never sleep.
198pub trait Sleeper: Send + Sync {
199    fn sleep(&self, duration: Duration) -> BoxFuture<'_, ()>;
200}
201
202/// The real clock and `tokio` sleep.
203#[derive(Debug, Clone, Copy, Default)]
204pub struct SystemClock;
205
206impl Clock for SystemClock {
207    fn now(&self) -> SystemTime {
208        SystemTime::now()
209    }
210}
211
212impl Sleeper for SystemClock {
213    fn sleep(&self, duration: Duration) -> BoxFuture<'_, ()> {
214        Box::pin(tokio::time::sleep(duration))
215    }
216}
217
218/// A manually driven clock for tests: `sleep` returns at once and advances
219/// the clock by the requested duration, which it also records.
220#[derive(Debug)]
221pub struct ManualClock {
222    now: Mutex<SystemTime>,
223    sleeps: Mutex<Vec<Duration>>,
224}
225
226impl ManualClock {
227    /// A clock that reads `start` until advanced.
228    pub fn new(start: SystemTime) -> Self {
229        Self {
230            now: Mutex::new(start),
231            sleeps: Mutex::new(Vec::new()),
232        }
233    }
234
235    /// Moves the clock forward by `by`.
236    pub fn advance(&self, by: Duration) {
237        *self.now.lock().expect("poisoned") += by;
238    }
239
240    /// Every duration passed to [`Sleeper::sleep`] so far, in order.
241    pub fn sleeps(&self) -> Vec<Duration> {
242        self.sleeps.lock().expect("poisoned").clone()
243    }
244}
245
246impl Clock for ManualClock {
247    fn now(&self) -> SystemTime {
248        *self.now.lock().expect("poisoned")
249    }
250}
251
252impl Sleeper for ManualClock {
253    fn sleep(&self, duration: Duration) -> BoxFuture<'_, ()> {
254        self.sleeps.lock().expect("poisoned").push(duration);
255        self.advance(duration);
256        Box::pin(std::future::ready(()))
257    }
258}
259
260/// Endpoints and client credentials, injected so tests use a mock server.
261#[derive(Debug, Clone, PartialEq, Eq)]
262pub struct AuthConfig {
263    /// Base of the OAuth2 endpoints (`/device_authorization`, `/token`).
264    pub auth_base: String,
265    /// Base of the API that [`Authenticator::get_json`] paths resolve against.
266    pub api_base: String,
267    /// The device-flow (login) client: [`CLIENT_ID`] in production.
268    pub client_id: String,
269    pub client_secret: String,
270    /// The client refreshes use: [`PKCE_CLIENT_ID`] in production (AC19).
271    pub pkce_client_id: String,
272    pub pkce_client_secret: String,
273}
274
275impl AuthConfig {
276    /// Tidal's production endpoints and the official app's credentials.
277    pub fn production() -> Self {
278        Self::with_bases(AUTH_BASE, API_BASE)
279    }
280
281    /// The production credentials against other base URLs (tests).
282    pub fn with_bases(auth_base: impl Into<String>, api_base: impl Into<String>) -> Self {
283        Self {
284            auth_base: auth_base.into(),
285            api_base: api_base.into(),
286            client_id: CLIENT_ID.into(),
287            client_secret: CLIENT_SECRET.into(),
288            pkce_client_id: PKCE_CLIENT_ID.into(),
289            pkce_client_secret: PKCE_CLIENT_SECRET.into(),
290        }
291    }
292
293    pub(crate) fn auth_url(&self, endpoint: &str) -> String {
294        join(&self.auth_base, endpoint)
295    }
296
297    pub(crate) fn api_url(&self, path: &str) -> String {
298        join(&self.api_base, path)
299    }
300}
301
302/// Which client a session's access token was refreshed under (AC19).
303#[derive(Debug, Clone, Copy, PartialEq, Eq)]
304pub enum RefreshClient {
305    /// The PKCE client: 16-bit tracks stream as FLAC.
306    Pkce,
307    /// The device-flow client the login used, after Tidal rejected the PKCE
308    /// client: 16-bit tracks stream as AAC.
309    DeviceFlow,
310}
311
312/// Warned (logged by the [`Authenticator`], printed by `login`) when a token
313/// of the device-flow client is used because the PKCE client failed (AC19).
314/// It names no cause: at login it covers any failure, outages included.
315pub const LOSSY_WARNING: &str = "Could not switch this session to the PKCE client: \
316     CD-quality tracks will stream as AAC instead of FLAC";
317
318/// One `grant_type=refresh_token` call to `/token` under `client`, returning
319/// the status and body.
320///
321/// The client's ID and secret go as form fields only, no basic auth: the
322/// request shape the spec 0003 probe got a `200` for under both clients.
323pub(crate) async fn post_refresh(
324    http: &reqwest::Client,
325    config: &AuthConfig,
326    client: RefreshClient,
327    refresh_token: &str,
328) -> Result<(u16, String), AuthError> {
329    let (id, secret) = match client {
330        RefreshClient::Pkce => (&config.pkce_client_id, &config.pkce_client_secret),
331        RefreshClient::DeviceFlow => (&config.client_id, &config.client_secret),
332    };
333    let response = http
334        .post(config.auth_url("token"))
335        .header("content-type", "application/x-www-form-urlencoded")
336        .body(form_body(&[
337            ("client_id", id),
338            ("client_secret", secret),
339            ("grant_type", "refresh_token"),
340            ("refresh_token", refresh_token),
341            ("scope", SCOPE),
342        ]))
343        .send()
344        .await
345        .map_err(|e| AuthError::transport(&e))?;
346    let status = response.status().as_u16();
347    let body = response
348        .text()
349        .await
350        .map_err(|e| AuthError::transport(&e))?;
351    Ok((status, body))
352}
353
354/// Whether a refresh response rejects the client rather than the refresh
355/// token, so the refresh is retried under the device-flow client (AC19).
356pub(crate) fn rejects_client(status: u16, body: &str) -> bool {
357    matches!(
358        (status, error_code(body)),
359        (400 | 401, Some("invalid_client" | "unauthorized_client"))
360    )
361}
362
363/// Joins a base URL and a path with exactly one slash between them.
364fn join(base: &str, path: &str) -> String {
365    format!(
366        "{}/{}",
367        base.trim_end_matches('/'),
368        path.trim_start_matches('/')
369    )
370}
371
372/// Encodes `pairs` as an `application/x-www-form-urlencoded` body.
373pub(crate) fn form_body(pairs: &[(&str, &str)]) -> String {
374    let mut url = reqwest::Url::parse("http://form.invalid/").expect("static URL");
375    url.query_pairs_mut().extend_pairs(pairs);
376    url.query().unwrap_or_default().to_owned()
377}
378
379/// A `200` from `POST /token`: a device-code grant or a refresh. Its `Debug`
380/// redacts the tokens.
381#[derive(Clone, PartialEq, Eq, Deserialize)]
382pub struct TokenGrant {
383    access_token: String,
384    #[serde(default)]
385    refresh_token: Option<String>,
386    expires_in: u64,
387    #[serde(default)]
388    user_id: Option<u64>,
389    #[serde(default)]
390    user: Option<GrantUser>,
391}
392
393#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
394struct GrantUser {
395    #[serde(rename = "countryCode", default)]
396    country_code: Option<String>,
397}
398
399impl fmt::Debug for TokenGrant {
400    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
401        f.debug_struct("TokenGrant")
402            .field("access_token", &"<redacted>")
403            .field(
404                "refresh_token",
405                &self.refresh_token.as_ref().map(|_| "<redacted>"),
406            )
407            .field("expires_in", &self.expires_in)
408            .field("user_id", &self.user_id)
409            .field("user", &self.user)
410            .finish()
411    }
412}
413
414impl TokenGrant {
415    /// Parses a `200` body of `POST /token`.
416    pub fn from_json(body: &str) -> Result<Self, AuthError> {
417        serde_json::from_str(body).map_err(|_| AuthError::Decode("token"))
418    }
419
420    fn expires_at(&self, now: SystemTime) -> SystemTime {
421        now + Duration::from_secs(self.expires_in)
422    }
423
424    /// The account's user ID and non-empty country, or which one is missing.
425    fn account(&self) -> Result<(u64, String), AuthError> {
426        let user_id = self.user_id.ok_or(AuthError::SessionInfo("user ID"))?;
427        let country = self
428            .country_code()
429            .ok_or(AuthError::SessionInfo("country code"))?;
430        Ok((user_id, country))
431    }
432
433    /// `user.countryCode`, when present and not empty.
434    fn country_code(&self) -> Option<String> {
435        self.user
436            .as_ref()?
437            .country_code
438            .clone()
439            .filter(|country| !country.is_empty())
440    }
441}
442
443/// Builds the session of a fresh login (AC3). Fails with
444/// [`AuthError::SessionInfo`] when the user ID or country is missing.
445pub fn session_from_login(grant: &TokenGrant, now: SystemTime) -> Result<Session, AuthError> {
446    let (user_id, country_code) = grant.account()?;
447    Ok(Session {
448        access_token: grant.access_token.clone(),
449        refresh_token: grant.refresh_token.clone().unwrap_or_default(),
450        expires_at: grant.expires_at(now),
451        user_id,
452        country_code,
453    })
454}
455
456/// Builds the session after a refresh of `previous` (AC3, AC6): a missing
457/// `refresh_token` keeps the old one, missing account data keeps the old
458/// values (with a warning).
459pub fn session_from_refresh(previous: &Session, grant: &TokenGrant, now: SystemTime) -> Session {
460    let (user_id, country_code) = match grant.account() {
461        Ok(account) => account,
462        Err(error) => {
463            tracing::warn!(%error, "refresh response lacks account data; keeping the previous values");
464            (previous.user_id, previous.country_code.clone())
465        }
466    };
467    Session {
468        access_token: grant.access_token.clone(),
469        refresh_token: grant
470            .refresh_token
471            .clone()
472            .unwrap_or_else(|| previous.refresh_token.clone()),
473        expires_at: grant.expires_at(now),
474        user_id,
475        country_code,
476    }
477}
478
479/// What one device-code poll says to do next (AC2).
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub enum PollOutcome {
482    /// Not approved yet: poll again after `interval`.
483    Pending { interval: Duration },
484    /// Approved: the tokens.
485    Granted(TokenGrant),
486}
487
488/// The OAuth2 error codes Tidal's `/token` is known or assumed to send. Only
489/// these are ever copied into an error, so a body echoing a token cannot leak
490/// it (AC15).
491const KNOWN_ERROR_CODES: [&str; 10] = [
492    "authorization_pending",
493    "slow_down",
494    "expired_token",
495    "access_denied",
496    "invalid_grant",
497    "invalid_client",
498    "invalid_request",
499    "invalid_scope",
500    "unauthorized_client",
501    "unsupported_grant_type",
502];
503
504/// The OAuth2 `error` code of a `/token` error body, if it is a known one.
505pub(crate) fn error_code(body: &str) -> Option<&'static str> {
506    #[derive(Deserialize)]
507    struct ErrorBody {
508        error: Option<String>,
509    }
510    let error = serde_json::from_str::<ErrorBody>(body).ok()?.error?;
511    KNOWN_ERROR_CODES.into_iter().find(|known| *known == error)
512}
513
514/// Maps one `/token` response to a device-code poll to what happens next
515/// (AC2). `interval` is the current poll interval. `5xx` and `429` count as
516/// "not yet", like transport errors.
517pub fn classify_poll(
518    status: u16,
519    body: &str,
520    interval: Duration,
521) -> Result<PollOutcome, AuthError> {
522    if (200..300).contains(&status) {
523        return TokenGrant::from_json(body).map(PollOutcome::Granted);
524    }
525    if status == 429 || status >= 500 {
526        return Ok(PollOutcome::Pending { interval });
527    }
528    match error_code(body) {
529        Some("authorization_pending") => Ok(PollOutcome::Pending { interval }),
530        Some("slow_down") => Ok(PollOutcome::Pending {
531            interval: interval + SLOW_DOWN_STEP,
532        }),
533        Some("expired_token") => Err(AuthError::CodeExpired),
534        Some("access_denied") => Err(AuthError::Denied),
535        code => Err(AuthError::Http {
536            status,
537            code: code.map(Into::into),
538        }),
539    }
540}
541
542/// How a failed refresh is handled (spec 0002, "When refresh fails").
543#[derive(Debug, Clone, Copy, PartialEq, Eq)]
544pub enum RefreshFailure {
545    /// The refresh token is rejected: a new login is needed.
546    SessionLost,
547    /// May succeed later; the session stays usable.
548    Transient,
549}
550
551/// Classifies a non-`2xx` refresh response (AC7). Network errors are
552/// [`RefreshFailure::Transient`] too, without reaching this function.
553pub fn classify_refresh_failure(status: u16, body: &str) -> RefreshFailure {
554    match (status, error_code(body)) {
555        // The client codes reach here only after the AC19 fallback to the
556        // device-flow client was rejected too.
557        (400 | 401, Some("invalid_grant" | "invalid_client" | "unauthorized_client")) => {
558            RefreshFailure::SessionLost
559        }
560        (401, _) if body.trim().is_empty() => RefreshFailure::SessionLost,
561        // `5xx`, `429` and anything the spec's table does not list: keep the
562        // session, the next request tries again.
563        _ => RefreshFailure::Transient,
564    }
565}
566
567/// Whether `session`'s access token expires within [`REFRESH_MARGIN`] of
568/// `now`, or already has (AC4).
569pub fn needs_refresh(session: &Session, now: SystemTime) -> bool {
570    match session.expires_at.duration_since(now) {
571        Ok(left) => left < REFRESH_MARGIN,
572        Err(_) => true,
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use std::sync::Arc;
580    use wiremock::matchers::{body_string_contains, method, path};
581    use wiremock::{Mock, MockServer, ResponseTemplate};
582
583    const T0: Duration = Duration::from_secs(1_800_000_000);
584
585    fn t0() -> SystemTime {
586        SystemTime::UNIX_EPOCH + T0
587    }
588
589    fn fixture(name: &str) -> &'static str {
590        match name {
591            "token_pending" => include_str!("../tests/fixtures/auth/token_pending.json"),
592            "token_slow_down" => include_str!("../tests/fixtures/auth/token_slow_down.json"),
593            "token_expired" => include_str!("../tests/fixtures/auth/token_expired.json"),
594            "token_denied" => include_str!("../tests/fixtures/auth/token_denied.json"),
595            "token_granted" => include_str!("../tests/fixtures/auth/token_granted.json"),
596            "token_granted_no_user" => {
597                include_str!("../tests/fixtures/auth/token_granted_no_user.json")
598            }
599            "token_granted_empty_country" => {
600                include_str!("../tests/fixtures/auth/token_granted_empty_country.json")
601            }
602            "token_granted_no_user_id" => {
603                include_str!("../tests/fixtures/auth/token_granted_no_user_id.json")
604            }
605            "token_refreshed" => include_str!("../tests/fixtures/auth/token_refreshed.json"),
606            "token_refreshed_rotated" => {
607                include_str!("../tests/fixtures/auth/token_refreshed_rotated.json")
608            }
609            "token_refreshed_country_changed" => {
610                include_str!("../tests/fixtures/auth/token_refreshed_country_changed.json")
611            }
612            "token_refreshed_no_user" => {
613                include_str!("../tests/fixtures/auth/token_refreshed_no_user.json")
614            }
615            "refresh_invalid_grant" => {
616                include_str!("../tests/fixtures/auth/refresh_invalid_grant.json")
617            }
618            "refresh_invalid_client" => {
619                include_str!("../tests/fixtures/auth/refresh_invalid_client.json")
620            }
621            "refresh_unauthorized_client" => {
622                include_str!("../tests/fixtures/auth/refresh_unauthorized_client.json")
623            }
624            "echoing_error" => include_str!("../tests/fixtures/auth/echoing_error.json"),
625            "echoing_malformed_grant" => {
626                include_str!("../tests/fixtures/auth/echoing_malformed_grant.json")
627            }
628            other => panic!("unknown fixture {other}"),
629        }
630    }
631
632    fn session(access: &str, refresh: &str, expires_at: SystemTime) -> Session {
633        Session {
634            access_token: access.into(),
635            refresh_token: refresh.into(),
636            expires_at,
637            user_id: 1,
638            country_code: "FI".into(),
639        }
640    }
641
642    #[test]
643    fn ac2_poll_outcome() {
644        let two = Duration::from_secs(2);
645        let granted = TokenGrant::from_json(fixture("token_granted")).unwrap();
646        let cases: [(&str, u16, &str, Result<PollOutcome, AuthError>); 8] = [
647            (
648                "pending keeps the interval",
649                400,
650                "token_pending",
651                Ok(PollOutcome::Pending { interval: two }),
652            ),
653            (
654                "slow_down adds 5 s",
655                400,
656                "token_slow_down",
657                Ok(PollOutcome::Pending {
658                    interval: Duration::from_secs(7),
659                }),
660            ),
661            (
662                "expired_token",
663                400,
664                "token_expired",
665                Err(AuthError::CodeExpired),
666            ),
667            ("access_denied", 400, "token_denied", Err(AuthError::Denied)),
668            (
669                "granted",
670                200,
671                "token_granted",
672                Ok(PollOutcome::Granted(granted)),
673            ),
674            (
675                "5xx is not yet",
676                503,
677                "token_pending",
678                Ok(PollOutcome::Pending { interval: two }),
679            ),
680            (
681                "other OAuth error",
682                401,
683                "refresh_invalid_client",
684                Err(AuthError::Http {
685                    status: 401,
686                    code: Some("invalid_client".into()),
687                }),
688            ),
689            (
690                "malformed 200",
691                200,
692                "echoing_malformed_grant",
693                Err(AuthError::Decode("token")),
694            ),
695        ];
696        for (name, status, body, expected) in cases {
697            assert_eq!(
698                classify_poll(status, fixture(body), two),
699                expected,
700                "{name}"
701            );
702        }
703    }
704
705    /// AC3, login half: the full device-code poll against a mock server that
706    /// would count any `/v1/sessions` call.
707    #[tokio::test]
708    async fn ac3_session_from_token_response() {
709        type Account = Result<(u64, &'static str), AuthError>;
710        let cases: [(&str, Account); 4] = [
711            ("token_granted", Ok((1, "FI"))),
712            (
713                "token_granted_no_user",
714                Err(AuthError::SessionInfo("country code")),
715            ),
716            (
717                "token_granted_empty_country",
718                Err(AuthError::SessionInfo("country code")),
719            ),
720            (
721                "token_granted_no_user_id",
722                Err(AuthError::SessionInfo("user ID")),
723            ),
724        ];
725        for (body, expected) in cases {
726            let server = MockServer::start().await;
727            // The poll only: the post-login refresh (AC19) gets a 404, which
728            // keeps the grant's session as is.
729            Mock::given(method("POST"))
730                .and(path("/oauth2/token"))
731                .and(body_string_contains("device_code=FAKE-DEVICE-CODE"))
732                .respond_with(
733                    ResponseTemplate::new(200).set_body_raw(fixture(body), "application/json"),
734                )
735                .expect(1)
736                .mount(&server)
737                .await;
738            Mock::given(path("/v1/sessions"))
739                .respond_with(ResponseTemplate::new(500))
740                .expect(0)
741                .mount(&server)
742                .await;
743            let clock = Arc::new(ManualClock::new(t0()));
744            let config = AuthConfig::with_bases(
745                format!("{}/oauth2", server.uri()),
746                format!("{}/v1", server.uri()),
747            );
748            let flow = DeviceFlow::with_time(config, clock.clone(), clock).unwrap();
749            let code = DeviceCode {
750                device_code: "FAKE-DEVICE-CODE".into(),
751                user_code: "ABCDE".into(),
752                verification_uri: "link.tidal.com".into(),
753                verification_uri_complete: None,
754                expires_in: Duration::from_secs(300),
755                interval: Duration::from_secs(2),
756            };
757            let store = MemoryStore::default();
758            let result = flow
759                .complete_login(&code, &store)
760                .await
761                .map(|login| login.session);
762            match expected {
763                Ok((user_id, country)) => {
764                    let session = result.unwrap_or_else(|e| panic!("{body}: {e}"));
765                    assert_eq!(
766                        (session.user_id, session.country_code.as_str()),
767                        (user_id, country),
768                        "{body}"
769                    );
770                    assert_eq!(session.expires_at, t0() + Duration::from_secs(14_400));
771                    assert_eq!(store.load(), Ok(Some(session)), "{body}: stored");
772                }
773                Err(error) => {
774                    assert_eq!(result, Err(error), "{body}");
775                    assert_eq!(store.load(), Ok(None), "{body}: nothing stored");
776                }
777            }
778            server.verify().await;
779        }
780
781        // Refresh half: a changed country is picked up, a missing one keeps
782        // the previous values.
783        let previous = session("FAKE-ACCESS", "FAKE-REFRESH", t0());
784        let refresh_cases = [
785            ("token_refreshed_country_changed", 1, "SE"),
786            ("token_refreshed_no_user", 1, "FI"),
787            ("token_refreshed", 1, "FI"),
788        ];
789        for (body, user_id, country) in refresh_cases {
790            let grant = TokenGrant::from_json(fixture(body)).unwrap();
791            let refreshed = session_from_refresh(&previous, &grant, t0());
792            assert_eq!(
793                (
794                    refreshed.access_token.as_str(),
795                    refreshed.user_id,
796                    refreshed.country_code.as_str()
797                ),
798                ("FAKE-ACCESS-2", user_id, country),
799                "{body}"
800            );
801        }
802    }
803
804    #[test]
805    fn ac4_needs_refresh() {
806        let cases = [
807            ("far future", Duration::from_secs(14_400), 0, false),
808            ("exactly 60 s", Duration::from_secs(60), 0, false),
809            ("59 s", Duration::from_secs(59), 0, true),
810            ("expires now", Duration::ZERO, 0, true),
811            ("expired", Duration::ZERO, 10, true),
812        ];
813        for (name, expires_in, elapsed, expected) in cases {
814            let s = session("FAKE-ACCESS", "FAKE-REFRESH", t0() + expires_in);
815            let now = t0() + Duration::from_secs(elapsed);
816            assert_eq!(needs_refresh(&s, now), expected, "{name}");
817        }
818    }
819
820    #[test]
821    fn ac6_refresh_token_kept_or_replaced() {
822        let previous = session("FAKE-ACCESS", "FAKE-REFRESH", t0());
823        let cases = [
824            ("token_refreshed", "FAKE-REFRESH"),
825            ("token_refreshed_rotated", "FAKE-REFRESH-2"),
826        ];
827        for (body, refresh) in cases {
828            let grant = TokenGrant::from_json(fixture(body)).unwrap();
829            let refreshed = session_from_refresh(&previous, &grant, t0());
830            assert_eq!(refreshed.refresh_token, refresh, "{body}");
831            assert_eq!(refreshed.access_token, "FAKE-ACCESS-2", "{body}");
832            assert_eq!(refreshed.expires_at, t0() + Duration::from_secs(14_400));
833        }
834    }
835
836    #[test]
837    fn ac7_classify_refresh_failure() {
838        use RefreshFailure::{SessionLost, Transient};
839        let cases = [
840            (400, "refresh_invalid_grant", SessionLost),
841            (401, "refresh_invalid_grant", SessionLost),
842            (400, "refresh_invalid_client", SessionLost),
843            (401, "refresh_invalid_client", SessionLost),
844            (400, "refresh_unauthorized_client", SessionLost),
845            (401, "refresh_unauthorized_client", SessionLost),
846            (401, "", SessionLost),
847            (500, "", Transient),
848            (502, "refresh_invalid_grant", Transient),
849            (503, "", Transient),
850            (429, "", Transient),
851            // Not in the spec's table: kept usable rather than logged out.
852            (400, "token_pending", Transient),
853            (403, "", Transient),
854        ];
855        for (status, body, expected) in cases {
856            let body = if body.is_empty() { "" } else { fixture(body) };
857            assert_eq!(
858                classify_refresh_failure(status, body),
859                expected,
860                "{status} {body}"
861            );
862        }
863    }
864
865    /// AC19: the warning is true whatever made the PKCE refresh fail.
866    #[test]
867    fn ac19_warning_names_no_cause() {
868        assert!(LOSSY_WARNING.contains("AAC"), "{LOSSY_WARNING}");
869        for cause in ["did not accept", "rejected", "refused"] {
870            assert!(!LOSSY_WARNING.contains(cause), "{LOSSY_WARNING}");
871        }
872    }
873
874    #[test]
875    fn ac15_no_tokens_in_debug_or_errors() {
876        let s = session("FAKE-ACCESS", "FAKE-REFRESH", t0());
877        let debug = format!("{s:?}");
878        assert!(!debug.contains("FAKE-ACCESS"), "{debug}");
879        assert!(!debug.contains("FAKE-REFRESH"), "{debug}");
880        assert!(debug.contains("<redacted>"), "{debug}");
881
882        let grant = TokenGrant::from_json(fixture("token_granted")).unwrap();
883        let debug = format!("{grant:?} {:?}", PollOutcome::Granted(grant.clone()));
884        assert!(!debug.contains("FAKE-ACCESS"), "{debug}");
885        assert!(!debug.contains("FAKE-REFRESH"), "{debug}");
886
887        // Errors built from server bodies that echo the tokens.
888        let two = Duration::from_secs(2);
889        let echoed = [
890            classify_poll(400, fixture("echoing_error"), two).unwrap_err(),
891            classify_poll(200, fixture("echoing_malformed_grant"), two).unwrap_err(),
892            TokenGrant::from_json(fixture("echoing_malformed_grant")).unwrap_err(),
893        ];
894        let mut errors = vec![
895            AuthError::Transport("connection refused".into()),
896            AuthError::Http {
897                status: 500,
898                code: None,
899            },
900            AuthError::Decode("token"),
901            AuthError::CodeExpired,
902            AuthError::Denied,
903            AuthError::SessionInfo("country code"),
904            AuthError::LoginRequired,
905            AuthError::Unauthorized,
906            AuthError::Store(StoreError::WrongPassphrase),
907        ];
908        errors.extend(echoed);
909        for error in &errors {
910            // Fails to compile when a variant is added, so it gets a case.
911            match error {
912                AuthError::Transport(_)
913                | AuthError::Http { .. }
914                | AuthError::Decode(_)
915                | AuthError::CodeExpired
916                | AuthError::Denied
917                | AuthError::SessionInfo(_)
918                | AuthError::LoginRequired
919                | AuthError::Unauthorized
920                | AuthError::Store(_) => {}
921            }
922            for text in [error.to_string(), format!("{error:?}")] {
923                assert!(!text.contains("FAKE-ACCESS"), "{text}");
924                assert!(!text.contains("FAKE-REFRESH"), "{text}");
925            }
926        }
927    }
928
929    #[test]
930    fn memory_store_round_trip() {
931        let session = session(
932            "FAKE-ACCESS",
933            "FAKE-REFRESH",
934            SystemTime::UNIX_EPOCH + Duration::from_secs(14_400),
935        );
936        let store = MemoryStore::default();
937        assert_eq!(store.load(), Ok(None));
938        store.save(&session).unwrap();
939        assert_eq!(store.load(), Ok(Some(session)));
940        store.delete().unwrap();
941        store.delete().unwrap();
942        assert_eq!(store.load(), Ok(None));
943    }
944
945    #[test]
946    fn form_body_encodes() {
947        assert_eq!(
948            form_body(&[("scope", SCOPE), ("grant_type", DEVICE_CODE_GRANT)]),
949            "scope=r_usr+w_usr+w_sub&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"
950        );
951    }
952}