1use std::fmt;
4use std::sync::Arc;
5use std::time::{Duration, SystemTime};
6
7use reqwest::{Method, StatusCode, Url};
8use serde::de::DeserializeOwned;
9use tokio::sync::{Mutex, watch};
10
11use crate::{ApiResponse, SUB_STATUS_NOT_AVAILABLE};
12
13use super::{
14 AuthConfig, AuthError, Clock, LOSSY_WARNING, LOST_RECHECK, RefreshClient, RefreshFailure,
15 Session, SessionStore, TokenGrant, classify_refresh_failure, error_code, form_body,
16 needs_refresh, post_refresh, rejects_client, session_from_refresh,
17};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum AuthStatus {
23 Active,
25 LoginRequired,
28}
29
30struct State {
32 session: Session,
33 lost_since_check: Option<SystemTime>,
35 refresh_client: Option<RefreshClient>,
37}
38
39pub struct Authenticator {
43 http: reqwest::Client,
44 config: AuthConfig,
45 store: Arc<dyn SessionStore>,
46 clock: Arc<dyn Clock>,
47 state: Mutex<State>,
48 status: watch::Sender<AuthStatus>,
49}
50
51impl fmt::Debug for Authenticator {
52 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53 f.debug_struct("Authenticator")
54 .field("config", &self.config)
55 .field("status", &*self.status.borrow())
56 .finish_non_exhaustive()
57 }
58}
59
60impl Authenticator {
61 pub fn new(
63 config: AuthConfig,
64 store: Arc<dyn SessionStore>,
65 session: Session,
66 clock: Arc<dyn Clock>,
67 ) -> Result<Self, AuthError> {
68 let http = reqwest::Client::builder()
69 .build()
70 .map_err(|e| AuthError::transport(&e))?;
71 Ok(Self {
72 http,
73 config,
74 store,
75 clock,
76 state: Mutex::new(State {
77 session,
78 lost_since_check: None,
79 refresh_client: None,
80 }),
81 status: watch::Sender::new(AuthStatus::Active),
82 })
83 }
84
85 pub fn from_store(
88 config: AuthConfig,
89 store: Arc<dyn SessionStore>,
90 clock: Arc<dyn Clock>,
91 ) -> Result<Option<Self>, AuthError> {
92 match store.load()? {
93 Some(session) => Self::new(config, store, session, clock).map(Some),
94 None => Ok(None),
95 }
96 }
97
98 pub fn status(&self) -> watch::Receiver<AuthStatus> {
100 self.status.subscribe()
101 }
102
103 pub async fn account(&self) -> (u64, String) {
105 let state = self.state.lock().await;
106 (state.session.user_id, state.session.country_code.clone())
107 }
108
109 pub async fn refresh_client(&self) -> Option<RefreshClient> {
112 self.state.lock().await.refresh_client
113 }
114
115 pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, AuthError> {
120 let response = self.get(path, &[], None).await?;
121 if !response.status().is_success() {
122 return Err(AuthError::Http {
123 status: response.status().as_u16(),
124 code: None,
125 });
126 }
127 response.json().map_err(|_| AuthError::Decode("API"))
128 }
129
130 pub async fn get(
141 &self,
142 path: &str,
143 query: &[(&str, &str)],
144 timeout: Option<Duration>,
145 ) -> Result<ApiResponse, AuthError> {
146 self.send(Method::GET, path, query, None, &[], timeout)
147 .await
148 }
149
150 pub async fn send(
155 &self,
156 method: Method,
157 path: &str,
158 query: &[(&str, &str)],
159 form: Option<&[(&str, &str)]>,
160 headers: &[(&str, &str)],
161 timeout: Option<Duration>,
162 ) -> Result<ApiResponse, AuthError> {
163 let url = self.api_url_with_query(path, query)?;
164 let request = Outgoing {
165 method,
166 url,
167 body: form.map(form_body),
168 headers,
169 timeout,
170 };
171 let token = self.valid_token().await?;
172 let mut response = self.send_once(&request, &token).await?;
173 if is_token_rejection(&response) {
174 let token = self.token_after_401(&token).await?;
175 response = self.send_once(&request, &token).await?;
176 if is_token_rejection(&response) {
177 return Err(AuthError::Unauthorized);
178 }
179 }
180 Ok(response)
181 }
182
183 fn api_url_with_query(&self, path: &str, query: &[(&str, &str)]) -> Result<Url, AuthError> {
184 let mut url = Url::parse(&self.config.api_url(path))
185 .map_err(|e| AuthError::Transport(format!("invalid API URL: {e}")))?;
186 if !query.is_empty() {
187 url.query_pairs_mut().extend_pairs(query);
188 }
189 Ok(url)
190 }
191
192 async fn send_once(
193 &self,
194 outgoing: &Outgoing<'_>,
195 token: &str,
196 ) -> Result<ApiResponse, AuthError> {
197 let mut request = self
198 .http
199 .request(outgoing.method.clone(), outgoing.url.clone())
200 .bearer_auth(token);
201 for (name, value) in outgoing.headers {
202 request = request.header(*name, *value);
203 }
204 if let Some(body) = &outgoing.body {
205 request = request
206 .header("content-type", "application/x-www-form-urlencoded")
207 .body(body.clone());
208 }
209 if let Some(timeout) = outgoing.timeout {
210 request = request.timeout(timeout);
211 }
212 let response = request.send().await.map_err(|e| AuthError::transport(&e))?;
213 let status = response.status();
214 let headers = response.headers().clone();
215 let body = response
216 .bytes()
217 .await
218 .map_err(|e| AuthError::transport(&e))?
219 .to_vec();
220 Ok(ApiResponse::new(status, headers, body))
221 }
222
223 async fn valid_token(&self) -> Result<String, AuthError> {
225 let mut state = self.state.lock().await;
226 self.leave_lost_state(&mut state)?;
227 if needs_refresh(&state.session, self.clock.now()) {
228 self.refresh(&mut state, Reason::Expiring).await?;
229 }
230 Ok(state.session.access_token.clone())
231 }
232
233 async fn token_after_401(&self, rejected: &str) -> Result<String, AuthError> {
235 let mut state = self.state.lock().await;
236 self.leave_lost_state(&mut state)?;
237 if state.session.access_token == rejected {
239 self.refresh(&mut state, Reason::Rejected).await?;
240 }
241 Ok(state.session.access_token.clone())
242 }
243
244 fn leave_lost_state(&self, state: &mut State) -> Result<(), AuthError> {
247 let Some(last_check) = state.lost_since_check else {
248 return Ok(());
249 };
250 let now = self.clock.now();
251 let due = now
252 .duration_since(last_check)
253 .is_ok_and(|elapsed| elapsed >= LOST_RECHECK);
254 if !due {
255 return Err(AuthError::LoginRequired);
256 }
257 state.lost_since_check = Some(now);
258 match self.load_stored() {
259 Some(stored) if stored.refresh_token != state.session.refresh_token => {
260 tracing::info!("found a new login in the session store");
261 state.session = stored;
262 state.lost_since_check = None;
263 self.set_status(AuthStatus::Active);
264 Ok(())
265 }
266 _ => Err(AuthError::LoginRequired),
267 }
268 }
269
270 async fn refresh(&self, state: &mut State, reason: Reason) -> Result<(), AuthError> {
275 if let Some(stored) = self.load_stored()
276 && stored.expires_at > state.session.expires_at
277 {
278 tracing::info!("adopting a newer session from the session store");
279 state.session = stored;
280 let usable = match reason {
281 Reason::Expiring => !needs_refresh(&state.session, self.clock.now()),
282 Reason::Rejected => true,
283 };
284 if usable {
285 return Ok(());
286 }
287 }
288 match self.refresh_once(state).await {
289 Err(Failure::Lost) => {}
290 other => return other.map_err(Failure::into_error),
291 }
292 if let Some(stored) = self.load_stored()
293 && stored.refresh_token != state.session.refresh_token
294 {
295 tracing::info!("refresh token rejected; retrying with the stored one");
296 state.session = stored;
297 match self.refresh_once(state).await {
298 Err(Failure::Lost) => {}
299 other => return other.map_err(Failure::into_error),
300 }
301 }
302 tracing::warn!("refresh token rejected: run \"tidal-player login\"");
303 state.lost_since_check = Some(self.clock.now());
304 self.set_status(AuthStatus::LoginRequired);
305 Err(AuthError::LoginRequired)
306 }
307
308 async fn refresh_once(&self, state: &mut State) -> Result<(), Failure> {
312 let token = &state.session.refresh_token;
313 let mut client = RefreshClient::Pkce;
314 let mut reply = post_refresh(&self.http, &self.config, client, token)
315 .await
316 .map_err(Failure::Transient)?;
317 if rejects_client(reply.0, &reply.1) {
318 tracing::warn!("{LOSSY_WARNING}");
319 client = RefreshClient::DeviceFlow;
320 reply = post_refresh(&self.http, &self.config, client, token)
321 .await
322 .map_err(Failure::Transient)?;
323 }
324 let (status, body) = reply;
325 if !(200..300).contains(&status) {
326 return match classify_refresh_failure(status, &body) {
327 RefreshFailure::SessionLost => Err(Failure::Lost),
328 RefreshFailure::Transient => Err(Failure::Transient(AuthError::Http {
329 status,
330 code: error_code(&body).map(Into::into),
331 })),
332 };
333 }
334 let grant = TokenGrant::from_json(&body).map_err(Failure::Transient)?;
335 let session = session_from_refresh(&state.session, &grant, self.clock.now());
336 if let Err(error) = self.store.save(&session) {
337 tracing::warn!(%error, "could not save the refreshed session");
338 }
339 state.session = session;
340 state.refresh_client = Some(client);
341 Ok(())
342 }
343
344 fn load_stored(&self) -> Option<Session> {
345 self.store.load().unwrap_or_else(|error| {
346 tracing::warn!(%error, "could not read the session store");
347 None
348 })
349 }
350
351 fn set_status(&self, status: AuthStatus) {
352 self.status.send_if_modified(|current| {
353 let changed = *current != status;
354 *current = status;
355 changed
356 });
357 }
358}
359
360struct Outgoing<'a> {
362 method: Method,
363 url: Url,
364 body: Option<String>,
365 headers: &'a [(&'a str, &'a str)],
366 timeout: Option<Duration>,
367}
368
369fn is_token_rejection(response: &ApiResponse) -> bool {
372 response.status() == StatusCode::UNAUTHORIZED
373 && response.sub_status() != Some(SUB_STATUS_NOT_AVAILABLE)
374}
375
376#[derive(Clone, Copy)]
378enum Reason {
379 Expiring,
381 Rejected,
383}
384
385enum Failure {
387 Lost,
389 Transient(AuthError),
391}
392
393impl Failure {
394 fn into_error(self) -> AuthError {
395 match self {
396 Self::Lost => AuthError::LoginRequired,
397 Self::Transient(error) => error,
398 }
399 }
400}