1#[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#[derive(Deserialize)]
27struct LoginResponse {
28 user_auth_token: Option<String>,
30 user: Option<LoginUser>,
32}
33
34#[derive(Deserialize)]
36struct LoginUser {
37 id: i64,
39}
40
41#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
43pub struct Subscription {
44 pub id: Option<i32>,
46 pub offer: Option<String>,
48 pub start_date: Option<String>,
50 pub end_date: Option<String>,
52 pub status: Option<String>,
54 pub is_active: Option<bool>,
56}
57
58#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
60pub struct User {
61 pub id: Option<i32>,
63 pub credential: Option<Credential>,
65 pub subscription: Option<Subscription>,
67 pub display_name: Option<String>,
69}
70
71pub fn authenticate_with_env(service: &mut QobuzApiService) -> Result<(), QobuzApiError> {
90 authenticate_with_env_from(service, |key| var(key))
91}
92
93pub 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
160pub 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
196async 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
239pub 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
275async 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
331pub 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}