Skip to main content

qobuz_api/api/
auth.rs

1//! Authentication methods for the Qobuz API.
2
3#[cfg(test)]
4pub mod proofs;
5
6use std::{
7    env::{VarError, var},
8    path::Path,
9};
10
11use {
12    md5::{Digest, Md5},
13    serde::{Deserialize, Serialize},
14    tokio::runtime::Runtime,
15    tracing::{error, info},
16};
17
18use crate::{
19    api::{http_client::HttpClient, requests::post, service::QobuzApiService},
20    credentials::{Credential, save_app_credentials, web::extract_from_web_player},
21    errors::QobuzApiError::{self, AuthenticationError, CredentialsError},
22    signing::to_hex,
23};
24
25/// Response from the Qobuz login endpoint.
26#[derive(Deserialize)]
27struct LoginResponse {
28    /// User authentication token returned on successful login.
29    user_auth_token: Option<String>,
30    /// User object returned on successful login.
31    user: Option<LoginUser>,
32}
33
34/// User data within the login response.
35#[derive(Deserialize)]
36struct LoginUser {
37    /// Numeric user ID.
38    id: i64,
39}
40
41/// User subscription details.
42#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
43pub struct Subscription {
44    /// Subscription ID.
45    pub id: Option<i32>,
46    /// Subscription plan name.
47    pub offer: Option<String>,
48    /// Subscription start date.
49    pub start_date: Option<String>,
50    /// Subscription end date.
51    pub end_date: Option<String>,
52    /// Subscription status.
53    pub status: Option<String>,
54    /// Whether the subscription is active.
55    pub is_active: Option<bool>,
56}
57
58/// A Qobuz user.
59#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
60pub struct User {
61    /// User ID.
62    pub id: Option<i32>,
63    /// User credentials/capabilities.
64    pub credential: Option<Credential>,
65    /// Subscription details.
66    pub subscription: Option<Subscription>,
67    /// Display name.
68    pub display_name: Option<String>,
69}
70
71/// Authenticates using environment variables.
72///
73/// Reads credentials in priority order:
74/// 1. `QOBUZ_USER_ID` + `QOBUZ_USER_AUTH_TOKEN` → token-based auth
75/// 2. `QOBUZ_EMAIL` + `QOBUZ_PASSWORD` → email/password auth
76/// 3. `QOBUZ_USERNAME` + `QOBUZ_PASSWORD` → username/password auth
77///
78/// # Arguments
79///
80/// * `service` - Mutable reference to the API service to authenticate
81///
82/// # Returns
83///
84/// `Ok(())` on successful authentication.
85///
86/// # Errors
87///
88/// Returns a `QobuzApiError` if no valid credentials are found or the login request fails.
89pub fn authenticate_with_env(service: &mut QobuzApiService) -> Result<(), QobuzApiError> {
90    authenticate_with_env_from(service, |key| var(key))
91}
92
93/// Env-abstracted authentication for deterministic testing without `set_var`.
94///
95/// # Arguments
96///
97/// * `service` - Mutable reference to the API service to authenticate
98/// * `get_env` - Function that retrieves environment variable values by name
99///
100/// # Returns
101///
102/// `Ok(())` on successful authentication.
103///
104/// # Errors
105///
106/// Returns `QobuzApiError` if no valid credentials are found or login fails.
107pub fn authenticate_with_env_from<E>(
108    service: &mut QobuzApiService,
109    get_env: E,
110) -> Result<(), QobuzApiError>
111where
112    E: Fn(&str) -> Result<String, VarError>,
113{
114    if let (Ok(user_id), Ok(token)) = (get_env("QOBUZ_USER_ID"), get_env("QOBUZ_USER_AUTH_TOKEN")) {
115        info!("Using token-based authentication from environment");
116
117        let rt = Runtime::new()?;
118        rt.block_on(login_with_token_inner(
119            service.http_client(),
120            service.base_url(),
121            &service.app_id,
122            user_id.trim(),
123            token.trim(),
124        ))?;
125
126        service.set_auth_token(token.trim().to_string());
127        info!(method = "token", "Authentication successful");
128        return Ok(());
129    }
130
131    let email = get_env("QOBUZ_EMAIL")
132        .or_else(|_| get_env("QOBUZ_USERNAME"))
133        .map_err(|var_error| AuthenticationError {
134            message: format!(
135                "No QOBUZ_USER_ID/QOBUZ_USER_AUTH_TOKEN or QOBUZ_EMAIL/QOBUZ_PASSWORD environment \
136                 variables found: {var_error}"
137            ),
138        })?;
139
140    let password = get_env("QOBUZ_PASSWORD").map_err(|var_error| AuthenticationError {
141        message: format!("QOBUZ_PASSWORD environment variable not found: {var_error}"),
142    })?;
143
144    info!("Using email/password authentication from environment");
145
146    let rt = Runtime::new()?;
147    let token = rt.block_on(login_inner(
148        service.http_client(),
149        service.base_url(),
150        &service.app_id,
151        email.trim(),
152        password.trim(),
153    ))?;
154
155    service.set_auth_token(token);
156    info!(method = "email", "Authentication successful");
157    Ok(())
158}
159
160/// Authenticates with email and MD5-hashed password via `POST /user/login`.
161///
162/// # Arguments
163///
164/// * `service` - Mutable reference to the API service to authenticate
165/// * `email` - User email address
166/// * `password` - User password (will be MD5-hashed before sending)
167///
168/// # Returns
169///
170/// `Ok(())` on successful authentication.
171///
172/// # Errors
173///
174/// Returns a `QobuzApiError` if the login request fails or no token is returned.
175pub fn login(
176    service: &mut QobuzApiService,
177    email: &str,
178    password: &str,
179) -> Result<(), QobuzApiError> {
180    info!(email = email, "Attempting login");
181
182    let rt = Runtime::new()?;
183    let token = rt.block_on(login_inner(
184        service.http_client(),
185        service.base_url(),
186        &service.app_id,
187        email,
188        password,
189    ))?;
190
191    service.set_auth_token(token);
192    info!("Login successful");
193    Ok(())
194}
195
196/// Inner login logic: hashes the password and sends the login POST request.
197///
198/// # Arguments
199///
200/// * `client` - HTTP client implementation
201/// * `base_url` - API base URL
202/// * `app_id` - Application ID
203/// * `email` - User email address
204/// * `password` - User password (will be MD5-hashed)
205///
206/// # Returns
207///
208/// The user authentication token on successful login.
209///
210/// # Errors
211///
212/// Returns a `QobuzApiError` if the HTTP request fails or the response contains no token.
213async fn login_inner(
214    client: &dyn HttpClient,
215    base_url: &str,
216    app_id: &str,
217    email: &str,
218    password: &str,
219) -> Result<String, QobuzApiError> {
220    let hashed = to_hex(&Md5::digest(password.as_bytes()));
221
222    let mut params = vec![
223        ("email".to_string(), email.to_string()),
224        ("password".to_string(), hashed),
225    ];
226
227    let response: LoginResponse =
228        post(client, base_url, "/user/login", &mut params, app_id, "").await?;
229
230    response.user_auth_token.ok_or_else(|| {
231        let err = AuthenticationError {
232            message: "Login succeeded but no user_auth_token in response".to_string(),
233        };
234        error!(error = %err, "Login response missing user_auth_token");
235        err
236    })
237}
238
239/// Authenticates with user ID and auth token via `POST /user/login`.
240///
241/// # Arguments
242///
243/// * `service` - Mutable reference to the API service to authenticate
244/// * `user_id` - Qobuz user ID
245/// * `auth_token` - User authentication token
246///
247/// # Returns
248///
249/// `Ok(())` on successful authentication.
250///
251/// # Errors
252///
253/// Returns a `QobuzApiError` if the login request fails or no token is confirmed.
254pub fn login_with_token(
255    service: &mut QobuzApiService,
256    user_id: &str,
257    auth_token: &str,
258) -> Result<(), QobuzApiError> {
259    info!(user_id = user_id, "Attempting token login");
260
261    let rt = Runtime::new()?;
262    rt.block_on(login_with_token_inner(
263        service.http_client(),
264        service.base_url(),
265        &service.app_id,
266        user_id,
267        auth_token,
268    ))?;
269
270    service.set_auth_token(auth_token.to_string());
271    info!("Token login successful");
272    Ok(())
273}
274
275/// Inner token login logic: sends the login POST request with user ID and token.
276///
277/// # Arguments
278///
279/// * `client` - HTTP client implementation
280/// * `base_url` - API base URL
281/// * `app_id` - Application ID
282/// * `user_id` - Qobuz user ID
283/// * `auth_token` - User authentication token
284///
285/// # Returns
286///
287/// `Ok(())` on successful token validation.
288///
289/// # Errors
290///
291/// Returns a `QobuzApiError` if the HTTP request fails or the response contains no token.
292async fn login_with_token_inner(
293    client: &dyn HttpClient,
294    base_url: &str,
295    app_id: &str,
296    user_id: &str,
297    auth_token: &str,
298) -> Result<(), QobuzApiError> {
299    let mut params = vec![
300        ("user_id".to_string(), user_id.to_string()),
301        ("user_auth_token".to_string(), auth_token.to_string()),
302    ];
303
304    let response: LoginResponse =
305        post(client, base_url, "/user/login", &mut params, app_id, "").await?;
306
307    if response.user_auth_token.is_none() {
308        let err = AuthenticationError {
309            message: "Token login succeeded but no user_auth_token in response".to_string(),
310        };
311        error!(error = %err, "Token login response missing user_auth_token");
312        return Err(err);
313    }
314
315    if let Some(user) = &response.user {
316        let returned_id = user.id.to_string();
317        if returned_id != user_id {
318            let err = AuthenticationError {
319                message: format!(
320                    "User ID mismatch: requested {user_id} but API returned {returned_id}"
321                ),
322            };
323            error!(error = %err, "Token login user ID mismatch");
324            return Err(err);
325        }
326    }
327
328    Ok(())
329}
330
331/// Re-extracts app credentials from the Qobuz web player.
332///
333/// Can only be called once per session. Returns error on subsequent calls.
334///
335/// # Arguments
336///
337/// * `service` - Mutable reference to the API service to refresh
338///
339/// # Returns
340///
341/// `Ok(())` on successful credential refresh.
342///
343/// # Errors
344///
345/// Returns a `QobuzApiError` if credentials have already been refreshed or extraction fails.
346pub fn refresh_app_credentials(service: &mut QobuzApiService) -> Result<(), QobuzApiError> {
347    if service.is_credentials_refreshed() {
348        let err = CredentialsError {
349            message: "Credentials can only be refreshed once per session".to_string(),
350        };
351        error!(error = %err, "Credential refresh attempted more than once per session");
352        return Err(err);
353    }
354
355    info!("Refreshing app credentials from web player");
356
357    let rt = Runtime::new()?;
358    let (app_id, app_secret) = rt.block_on(extract_from_web_player()).map_err(|e| {
359        error!(error = %e, "Web player credential extraction failed; manual configuration required");
360        e
361    })?;
362
363    let env_path = Path::new(".env");
364    save_app_credentials(env_path, &app_id, &app_secret)?;
365
366    service.app_id = app_id;
367    service.app_secret = app_secret;
368    service.rebuild_http_client()?;
369    service.mark_credentials_refreshed();
370
371    info!("App credentials refreshed successfully");
372    Ok(())
373}