use std::fmt;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
use reqwest::{Method, StatusCode, Url};
use serde::de::DeserializeOwned;
use tokio::sync::{Mutex, watch};
use crate::{ApiResponse, SUB_STATUS_NOT_AVAILABLE};
use super::{
AuthConfig, AuthError, Clock, LOSSY_WARNING, LOST_RECHECK, RefreshClient, RefreshFailure,
Session, SessionStore, TokenGrant, classify_refresh_failure, error_code, form_body,
needs_refresh, post_refresh, rejects_client, session_from_refresh,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthStatus {
Active,
LoginRequired,
}
struct State {
session: Session,
lost_since_check: Option<SystemTime>,
refresh_client: Option<RefreshClient>,
}
pub struct Authenticator {
http: reqwest::Client,
config: AuthConfig,
store: Arc<dyn SessionStore>,
clock: Arc<dyn Clock>,
state: Mutex<State>,
status: watch::Sender<AuthStatus>,
}
impl fmt::Debug for Authenticator {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Authenticator")
.field("config", &self.config)
.field("status", &*self.status.borrow())
.finish_non_exhaustive()
}
}
impl Authenticator {
pub fn new(
config: AuthConfig,
store: Arc<dyn SessionStore>,
session: Session,
clock: Arc<dyn Clock>,
) -> Result<Self, AuthError> {
let http = reqwest::Client::builder()
.build()
.map_err(|e| AuthError::transport(&e))?;
Ok(Self {
http,
config,
store,
clock,
state: Mutex::new(State {
session,
lost_since_check: None,
refresh_client: None,
}),
status: watch::Sender::new(AuthStatus::Active),
})
}
pub fn from_store(
config: AuthConfig,
store: Arc<dyn SessionStore>,
clock: Arc<dyn Clock>,
) -> Result<Option<Self>, AuthError> {
match store.load()? {
Some(session) => Self::new(config, store, session, clock).map(Some),
None => Ok(None),
}
}
pub fn status(&self) -> watch::Receiver<AuthStatus> {
self.status.subscribe()
}
pub async fn account(&self) -> (u64, String) {
let state = self.state.lock().await;
(state.session.user_id, state.session.country_code.clone())
}
pub async fn refresh_client(&self) -> Option<RefreshClient> {
self.state.lock().await.refresh_client
}
pub async fn get_json<T: DeserializeOwned>(&self, path: &str) -> Result<T, AuthError> {
let response = self.get(path, &[], None).await?;
if !response.status().is_success() {
return Err(AuthError::Http {
status: response.status().as_u16(),
code: None,
});
}
response.json().map_err(|_| AuthError::Decode("API"))
}
pub async fn get(
&self,
path: &str,
query: &[(&str, &str)],
timeout: Option<Duration>,
) -> Result<ApiResponse, AuthError> {
self.send(Method::GET, path, query, None, &[], timeout)
.await
}
pub async fn send(
&self,
method: Method,
path: &str,
query: &[(&str, &str)],
form: Option<&[(&str, &str)]>,
headers: &[(&str, &str)],
timeout: Option<Duration>,
) -> Result<ApiResponse, AuthError> {
let url = self.api_url_with_query(path, query)?;
let request = Outgoing {
method,
url,
body: form.map(form_body),
headers,
timeout,
};
let token = self.valid_token().await?;
let mut response = self.send_once(&request, &token).await?;
if is_token_rejection(&response) {
let token = self.token_after_401(&token).await?;
response = self.send_once(&request, &token).await?;
if is_token_rejection(&response) {
return Err(AuthError::Unauthorized);
}
}
Ok(response)
}
fn api_url_with_query(&self, path: &str, query: &[(&str, &str)]) -> Result<Url, AuthError> {
let mut url = Url::parse(&self.config.api_url(path))
.map_err(|e| AuthError::Transport(format!("invalid API URL: {e}")))?;
if !query.is_empty() {
url.query_pairs_mut().extend_pairs(query);
}
Ok(url)
}
async fn send_once(
&self,
outgoing: &Outgoing<'_>,
token: &str,
) -> Result<ApiResponse, AuthError> {
let mut request = self
.http
.request(outgoing.method.clone(), outgoing.url.clone())
.bearer_auth(token);
for (name, value) in outgoing.headers {
request = request.header(*name, *value);
}
if let Some(body) = &outgoing.body {
request = request
.header("content-type", "application/x-www-form-urlencoded")
.body(body.clone());
}
if let Some(timeout) = outgoing.timeout {
request = request.timeout(timeout);
}
let response = request.send().await.map_err(|e| AuthError::transport(&e))?;
let status = response.status();
let headers = response.headers().clone();
let body = response
.bytes()
.await
.map_err(|e| AuthError::transport(&e))?
.to_vec();
Ok(ApiResponse::new(status, headers, body))
}
async fn valid_token(&self) -> Result<String, AuthError> {
let mut state = self.state.lock().await;
self.leave_lost_state(&mut state)?;
if needs_refresh(&state.session, self.clock.now()) {
self.refresh(&mut state, Reason::Expiring).await?;
}
Ok(state.session.access_token.clone())
}
async fn token_after_401(&self, rejected: &str) -> Result<String, AuthError> {
let mut state = self.state.lock().await;
self.leave_lost_state(&mut state)?;
if state.session.access_token == rejected {
self.refresh(&mut state, Reason::Rejected).await?;
}
Ok(state.session.access_token.clone())
}
fn leave_lost_state(&self, state: &mut State) -> Result<(), AuthError> {
let Some(last_check) = state.lost_since_check else {
return Ok(());
};
let now = self.clock.now();
let due = now
.duration_since(last_check)
.is_ok_and(|elapsed| elapsed >= LOST_RECHECK);
if !due {
return Err(AuthError::LoginRequired);
}
state.lost_since_check = Some(now);
match self.load_stored() {
Some(stored) if stored.refresh_token != state.session.refresh_token => {
tracing::info!("found a new login in the session store");
state.session = stored;
state.lost_since_check = None;
self.set_status(AuthStatus::Active);
Ok(())
}
_ => Err(AuthError::LoginRequired),
}
}
async fn refresh(&self, state: &mut State, reason: Reason) -> Result<(), AuthError> {
if let Some(stored) = self.load_stored()
&& stored.expires_at > state.session.expires_at
{
tracing::info!("adopting a newer session from the session store");
state.session = stored;
let usable = match reason {
Reason::Expiring => !needs_refresh(&state.session, self.clock.now()),
Reason::Rejected => true,
};
if usable {
return Ok(());
}
}
match self.refresh_once(state).await {
Err(Failure::Lost) => {}
other => return other.map_err(Failure::into_error),
}
if let Some(stored) = self.load_stored()
&& stored.refresh_token != state.session.refresh_token
{
tracing::info!("refresh token rejected; retrying with the stored one");
state.session = stored;
match self.refresh_once(state).await {
Err(Failure::Lost) => {}
other => return other.map_err(Failure::into_error),
}
}
tracing::warn!("refresh token rejected: run \"tidal-player login\"");
state.lost_since_check = Some(self.clock.now());
self.set_status(AuthStatus::LoginRequired);
Err(AuthError::LoginRequired)
}
async fn refresh_once(&self, state: &mut State) -> Result<(), Failure> {
let token = &state.session.refresh_token;
let mut client = RefreshClient::Pkce;
let mut reply = post_refresh(&self.http, &self.config, client, token)
.await
.map_err(Failure::Transient)?;
if rejects_client(reply.0, &reply.1) {
tracing::warn!("{LOSSY_WARNING}");
client = RefreshClient::DeviceFlow;
reply = post_refresh(&self.http, &self.config, client, token)
.await
.map_err(Failure::Transient)?;
}
let (status, body) = reply;
if !(200..300).contains(&status) {
return match classify_refresh_failure(status, &body) {
RefreshFailure::SessionLost => Err(Failure::Lost),
RefreshFailure::Transient => Err(Failure::Transient(AuthError::Http {
status,
code: error_code(&body).map(Into::into),
})),
};
}
let grant = TokenGrant::from_json(&body).map_err(Failure::Transient)?;
let session = session_from_refresh(&state.session, &grant, self.clock.now());
if let Err(error) = self.store.save(&session) {
tracing::warn!(%error, "could not save the refreshed session");
}
state.session = session;
state.refresh_client = Some(client);
Ok(())
}
fn load_stored(&self) -> Option<Session> {
self.store.load().unwrap_or_else(|error| {
tracing::warn!(%error, "could not read the session store");
None
})
}
fn set_status(&self, status: AuthStatus) {
self.status.send_if_modified(|current| {
let changed = *current != status;
*current = status;
changed
});
}
}
struct Outgoing<'a> {
method: Method,
url: Url,
body: Option<String>,
headers: &'a [(&'a str, &'a str)],
timeout: Option<Duration>,
}
fn is_token_rejection(response: &ApiResponse) -> bool {
response.status() == StatusCode::UNAUTHORIZED
&& response.sub_status() != Some(SUB_STATUS_NOT_AVAILABLE)
}
#[derive(Clone, Copy)]
enum Reason {
Expiring,
Rejected,
}
enum Failure {
Lost,
Transient(AuthError),
}
impl Failure {
fn into_error(self) -> AuthError {
match self {
Self::Lost => AuthError::LoginRequired,
Self::Transient(error) => error,
}
}
}