1use 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#[derive(Deserialize)]
24struct LoginResponse {
25 user_auth_token: Option<String>,
27 user: Option<LoginUser>,
29}
30
31#[derive(Deserialize)]
33struct LoginUser {
34 id: i64,
36}
37
38pub fn authenticate_with_env(service: &mut QobuzApiService) -> Result<(), QobuzApiError> {
57 authenticate_with_env_from(service, |key| var(key))
58}
59
60pub 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
127pub 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
163async 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
206pub 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
242async 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
298pub 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;