Skip to main content

qobuz_api/api/
auth.rs

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