wme_client/auth.rs
1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4
5use parking_lot::Mutex;
6use serde::Deserialize;
7
8use crate::{ClientError, HttpTransport, Result};
9
10/// Authentication tokens returned from login.
11#[derive(Debug, Clone)]
12pub struct Tokens {
13 /// ID token (JWT with user claims)
14 pub id_token: String,
15 /// Access token for API requests
16 pub access_token: String,
17 /// Refresh token for obtaining new access tokens
18 pub refresh_token: String,
19 /// Token expiration time
20 pub expires_at: Instant,
21}
22
23/// Thread-safe token manager with lazy refresh.
24#[derive(Clone)]
25pub struct TokenManager {
26 state: Arc<Mutex<TokenState>>,
27 transport: Arc<dyn HttpTransport>,
28 config: AuthConfig,
29}
30
31#[derive(Debug, Clone)]
32struct TokenState {
33 tokens: Option<Tokens>,
34}
35
36/// Authentication configuration.
37#[derive(Debug, Clone)]
38pub struct AuthConfig {
39 /// Username for authentication
40 pub username: String,
41 /// Password for authentication (only used for initial login)
42 pub password: String,
43 /// Authentication endpoint URL
44 pub auth_url: String,
45}
46
47impl TokenManager {
48 /// Create a new token manager.
49 pub fn new(transport: Arc<dyn HttpTransport>, config: AuthConfig) -> Self {
50 Self {
51 state: Arc::new(Mutex::new(TokenState { tokens: None })),
52 transport,
53 config,
54 }
55 }
56
57 /// Get a valid access token, refreshing if necessary.
58 pub async fn get_access_token(&self) -> Result<String> {
59 // Check if we need to refresh (without holding lock across await)
60 let needs_refresh = {
61 let state = self.state.lock();
62 match &state.tokens {
63 Some(tokens) => Instant::now() + Duration::from_secs(300) > tokens.expires_at,
64 None => true,
65 }
66 };
67
68 if needs_refresh {
69 self.refresh().await?;
70 }
71
72 let state = self.state.lock();
73 state
74 .tokens
75 .as_ref()
76 .map(|t| t.access_token.clone())
77 .ok_or(ClientError::Auth("No tokens available".to_string()))
78 }
79
80 /// Get the ID token (contains user claims).
81 pub fn id_token(&self) -> Option<String> {
82 let state = self.state.lock();
83 state.tokens.as_ref().map(|t| t.id_token.clone())
84 }
85
86 /// Get all tokens if available.
87 ///
88 /// Returns the tokens including access_token, refresh_token, and expiration.
89 /// This is useful for CLI applications that need to store tokens after login.
90 pub fn get_tokens(&self) -> Option<Tokens> {
91 let state = self.state.lock();
92 state.tokens.clone()
93 }
94
95 /// Set tokens from external source (e.g., config file).
96 ///
97 /// This allows the CLI to restore tokens from a previous session.
98 /// The tokens will be used for authentication and refreshed automatically when needed.
99 pub fn set_tokens(&self, tokens: Tokens) {
100 let mut state = self.state.lock();
101 state.tokens = Some(tokens);
102 }
103
104 /// Authenticate and obtain tokens.
105 ///
106 /// This method performs authentication if needed:
107 /// - If tokens exist and are valid, does nothing
108 /// - If tokens are expired, attempts to refresh
109 /// - If no tokens exist, performs fresh login
110 ///
111 /// After calling this, you can use [`TokenManager::get_tokens`] to retrieve the tokens for storage.
112 pub async fn authenticate(&self) -> Result<()> {
113 // Simply call get_access_token which handles all the auth logic
114 let _ = self.get_access_token().await?;
115 Ok(())
116 }
117
118 /// Refresh the access token using the refresh token endpoint.
119 ///
120 /// This method attempts to refresh the access token using the stored refresh token.
121 /// If successful, the new access token and ID token are stored, while the refresh
122 /// token remains unchanged.
123 ///
124 /// # Errors
125 ///
126 /// Returns an error if:
127 /// - No refresh token is available
128 /// - The refresh request fails
129 /// - The response cannot be parsed
130 ///
131 /// # Example
132 ///
133 /// ```rust,no_run
134 /// # use wme_client::auth::TokenManager;
135 /// # async fn example(token_manager: &TokenManager) -> Result<(), Box<dyn std::error::Error>> {
136 /// token_manager.refresh().await?;
137 /// # Ok(())
138 /// # }
139 /// ```
140 pub async fn refresh(&self) -> Result<()> {
141 // Check if we have a refresh token (without holding lock across await)
142 let has_refresh_token = {
143 let state = self.state.lock();
144 state
145 .tokens
146 .as_ref()
147 .map(|t| !t.refresh_token.is_empty())
148 .unwrap_or(false)
149 };
150
151 if has_refresh_token {
152 // Try to use refresh token endpoint
153 return self.refresh_with_token().await;
154 }
155
156 // No refresh token available, do fresh login
157 let tokens = self.login().await?;
158
159 // Store the new tokens
160 let mut state = self.state.lock();
161 state.tokens = Some(tokens);
162
163 Ok(())
164 }
165
166 /// Refresh using the /v1/token-refresh endpoint.
167 async fn refresh_with_token(&self) -> Result<()> {
168 let refresh_token = {
169 let state = self.state.lock();
170 state
171 .tokens
172 .as_ref()
173 .map(|t| t.refresh_token.clone())
174 .ok_or(ClientError::Auth("No refresh token available".to_string()))?
175 };
176
177 let url = format!("{}/v1/token-refresh", self.config.auth_url);
178
179 let body = serde_json::json!({
180 "username": self.config.username,
181 "refresh_token": refresh_token,
182 });
183
184 let mut headers = std::collections::HashMap::new();
185 headers.insert("Content-Type".to_string(), "application/json".to_string());
186
187 let response = self
188 .transport
189 .request(
190 reqwest::Method::POST,
191 &url,
192 Some(headers),
193 Some(body.to_string()),
194 )
195 .await?;
196
197 if !response.status().is_success() {
198 // If refresh fails, try fresh login
199 if response.status().as_u16() == 401 {
200 return self.login().await.map(|tokens| {
201 let mut state = self.state.lock();
202 state.tokens = Some(tokens);
203 });
204 }
205
206 return Err(ClientError::Auth(format!(
207 "Token refresh failed: {}",
208 response.status()
209 )));
210 }
211
212 let refresh_response: TokenRefreshResponse =
213 response.json().await.map_err(ClientError::from)?;
214
215 // Update tokens (keep the same refresh token)
216 let mut state = self.state.lock();
217 if let Some(existing) = &mut state.tokens {
218 existing.id_token = refresh_response.id_token;
219 existing.access_token = refresh_response.access_token;
220 existing.expires_at = Instant::now() + Duration::from_secs(refresh_response.expires_in);
221 }
222
223 Ok(())
224 }
225
226 /// Perform initial login using /v1/login endpoint.
227 async fn login(&self) -> Result<Tokens> {
228 let url = format!("{}/v1/login", self.config.auth_url);
229
230 let body = serde_json::json!({
231 "username": self.config.username,
232 "password": self.config.password,
233 });
234
235 let mut headers = std::collections::HashMap::new();
236 headers.insert("Content-Type".to_string(), "application/json".to_string());
237
238 tracing::debug!(
239 "Login request to {} with username: {}",
240 url,
241 self.config.username
242 );
243
244 let response = self
245 .transport
246 .request(
247 reqwest::Method::POST,
248 &url,
249 Some(headers),
250 Some(body.to_string()),
251 )
252 .await?;
253
254 if !response.status().is_success() {
255 let status = response.status();
256 let body_text = response.text().await.unwrap_or_default();
257 // Log full response body at debug level only (may contain sensitive info)
258 tracing::debug!("Login failed with status {}: {}", status, body_text);
259 return Err(ClientError::Auth(format!("Login failed: {}", status)));
260 }
261
262 let body_text = response.text().await.map_err(ClientError::from)?;
263 tracing::debug!("Login response: {}", body_text);
264
265 let login_response: LoginResponse = serde_json::from_str(&body_text).map_err(|e| {
266 tracing::debug!("Failed to parse login response: {}", e);
267 ClientError::from(e)
268 })?;
269
270 Ok(Tokens {
271 id_token: login_response.id_token,
272 access_token: login_response.access_token,
273 refresh_token: login_response.refresh_token,
274 expires_at: Instant::now() + Duration::from_secs(login_response.expires_in),
275 })
276 }
277
278 /// Revoke the current token using /v1/token-revoke endpoint.
279 ///
280 /// This invalidates the refresh token on the server, preventing
281 /// any future token refreshes. The local token state is cleared
282 /// regardless of whether the server request succeeds.
283 ///
284 /// # Errors
285 ///
286 /// Returns an error if no tokens are available to revoke or if
287 /// the HTTP request fails.
288 ///
289 /// # Example
290 ///
291 /// ```rust,no_run
292 /// # use wme_client::auth::TokenManager;
293 /// # async fn example(token_manager: &TokenManager) -> Result<(), Box<dyn std::error::Error>> {
294 /// token_manager.revoke_token().await?;
295 /// # Ok(())
296 /// # }
297 /// ```
298 pub async fn revoke_token(&self) -> Result<()> {
299 let refresh_token = {
300 let state = self.state.lock();
301 state
302 .tokens
303 .as_ref()
304 .map(|t| t.refresh_token.clone())
305 .ok_or(ClientError::Auth("No tokens to revoke".to_string()))?
306 };
307
308 let url = format!("{}/v1/token-revoke", self.config.auth_url);
309
310 let body = serde_json::json!({
311 "refresh_token": refresh_token,
312 });
313
314 let mut headers = std::collections::HashMap::new();
315 headers.insert("Content-Type".to_string(), "application/json".to_string());
316
317 let _response = self
318 .transport
319 .request(
320 reqwest::Method::POST,
321 &url,
322 Some(headers),
323 Some(body.to_string()),
324 )
325 .await?;
326
327 // Clear local state regardless of server response
328 self.clear_state();
329
330 Ok(())
331 }
332
333 /// Clear internal state (force refresh on next use).
334 pub fn clear_state(&self) {
335 let mut state = self.state.lock();
336 state.tokens = None;
337 }
338
339 /// Trigger forgot password email.
340 ///
341 /// Sends a password reset email to the user with a confirmation code.
342 /// The code can be used with [`TokenManager::forgot_password_confirm`] to set a new password.
343 ///
344 /// # Arguments
345 ///
346 /// * `username` - The username to send the reset email to
347 ///
348 /// # Errors
349 ///
350 /// Returns an error if the request fails or the user is not found.
351 ///
352 /// # Example
353 ///
354 /// ```rust,no_run
355 /// # use wme_client::auth::TokenManager;
356 /// # async fn example(token_manager: &TokenManager) -> Result<(), Box<dyn std::error::Error>> {
357 /// token_manager.forgot_password("user@example.com").await?;
358 /// # Ok(())
359 /// # }
360 /// ```
361 pub async fn forgot_password(&self, username: &str) -> Result<()> {
362 let url = format!("{}/v1/forgot-password", self.config.auth_url);
363
364 let body = serde_json::json!({
365 "username": username,
366 });
367
368 let mut headers = std::collections::HashMap::new();
369 headers.insert("Content-Type".to_string(), "application/json".to_string());
370
371 let response = self
372 .transport
373 .request(
374 reqwest::Method::POST,
375 &url,
376 Some(headers),
377 Some(body.to_string()),
378 )
379 .await?;
380
381 if !response.status().is_success() {
382 return Err(ClientError::Auth(format!(
383 "Forgot password request failed: {}",
384 response.status()
385 )));
386 }
387
388 Ok(())
389 }
390
391 /// Confirm forgot password with code.
392 ///
393 /// Completes the password reset flow by providing the confirmation code
394 /// received via email and the new password.
395 ///
396 /// # Arguments
397 ///
398 /// * `username` - The username being reset
399 /// * `password` - The new password to set
400 /// * `code` - The confirmation code from the email
401 ///
402 /// # Errors
403 ///
404 /// Returns an error if the code is invalid, expired, or the request fails.
405 ///
406 /// # Example
407 ///
408 /// ```rust,no_run
409 /// # use wme_client::auth::TokenManager;
410 /// # async fn example(token_manager: &TokenManager) -> Result<(), Box<dyn std::error::Error>> {
411 /// token_manager.forgot_password_confirm("user@example.com", "new_password", "123456").await?;
412 /// # Ok(())
413 /// # }
414 /// ```
415 pub async fn forgot_password_confirm(
416 &self,
417 username: &str,
418 password: &str,
419 code: &str,
420 ) -> Result<()> {
421 let url = format!("{}/v1/forgot-password-confirm", self.config.auth_url);
422
423 let body = serde_json::json!({
424 "username": username,
425 "password": password,
426 "code": code,
427 });
428
429 let mut headers = std::collections::HashMap::new();
430 headers.insert("Content-Type".to_string(), "application/json".to_string());
431
432 let response = self
433 .transport
434 .request(
435 reqwest::Method::POST,
436 &url,
437 Some(headers),
438 Some(body.to_string()),
439 )
440 .await?;
441
442 if !response.status().is_success() {
443 return Err(ClientError::Auth(format!(
444 "Password reset confirmation failed: {}",
445 response.status()
446 )));
447 }
448
449 Ok(())
450 }
451
452 /// Change password (requires authentication).
453 ///
454 /// Changes the password for the authenticated user. The user must
455 /// provide their current password for verification.
456 ///
457 /// # Arguments
458 ///
459 /// * `previous_password` - The current password
460 /// * `proposed_password` - The new password to set
461 ///
462 /// # Errors
463 ///
464 /// Returns an error if the current password is incorrect or the request fails.
465 ///
466 /// # Example
467 ///
468 /// ```rust,no_run
469 /// # use wme_client::auth::TokenManager;
470 /// # async fn example(token_manager: &TokenManager) -> Result<(), Box<dyn std::error::Error>> {
471 /// token_manager.change_password("old_password", "new_password").await?;
472 /// # Ok(())
473 /// # }
474 /// ```
475 pub async fn change_password(
476 &self,
477 previous_password: &str,
478 proposed_password: &str,
479 ) -> Result<()> {
480 let url = format!("{}/v1/change-password", self.config.auth_url);
481 let headers = self.auth_headers().await?;
482
483 let body = serde_json::json!({
484 "previous_password": previous_password,
485 "proposed_password": proposed_password,
486 });
487
488 let response = self
489 .transport
490 .request(reqwest::Method::POST, &url, headers, Some(body.to_string()))
491 .await?;
492
493 if !response.status().is_success() {
494 return Err(ClientError::Auth(format!(
495 "Password change failed: {}",
496 response.status()
497 )));
498 }
499
500 Ok(())
501 }
502
503 /// Set new password for NEW_PASSWORD_REQUIRED challenge.
504 ///
505 /// Completes the authentication flow when a user is required to set
506 /// a new password on first login or after a password reset.
507 ///
508 /// # Arguments
509 ///
510 /// * `username` - The username
511 /// * `session` - The session identifier from the authentication challenge
512 /// * `new_password` - The new password to set
513 ///
514 /// # Errors
515 ///
516 /// Returns an error if the session is invalid, password doesn't meet
517 /// requirements, or the request fails.
518 ///
519 /// # Example
520 ///
521 /// ```rust,no_run
522 /// # use wme_client::auth::TokenManager;
523 /// # async fn example(token_manager: &TokenManager) -> Result<(), Box<dyn std::error::Error>> {
524 /// token_manager.set_new_password("user@example.com", "session_id", "new_secure_password").await?;
525 /// # Ok(())
526 /// # }
527 /// ```
528 pub async fn set_new_password(
529 &self,
530 username: &str,
531 session: &str,
532 new_password: &str,
533 ) -> Result<()> {
534 let url = format!("{}/v1/set-new-password", self.config.auth_url);
535
536 let body = serde_json::json!({
537 "username": username,
538 "session": session,
539 "new_password": new_password,
540 });
541
542 let mut headers = std::collections::HashMap::new();
543 headers.insert("Content-Type".to_string(), "application/json".to_string());
544
545 let response = self
546 .transport
547 .request(
548 reqwest::Method::POST,
549 &url,
550 Some(headers),
551 Some(body.to_string()),
552 )
553 .await?;
554
555 if !response.status().is_success() {
556 return Err(ClientError::Auth(format!(
557 "Set new password failed: {}",
558 response.status()
559 )));
560 }
561
562 Ok(())
563 }
564
565 /// Get authentication headers.
566 async fn auth_headers(&self) -> Result<Option<HashMap<String, String>>> {
567 let token = self.get_access_token().await?;
568 let mut headers = HashMap::new();
569 headers.insert("Authorization".to_string(), format!("Bearer {}", token));
570 Ok(Some(headers))
571 }
572}
573
574/// Login response from /v1/login endpoint.
575#[derive(Debug, Deserialize)]
576struct LoginResponse {
577 id_token: String,
578 access_token: String,
579 refresh_token: String,
580 expires_in: u64,
581}
582
583/// Token refresh response from /v1/token-refresh endpoint.
584#[derive(Debug, Deserialize)]
585struct TokenRefreshResponse {
586 id_token: String,
587 access_token: String,
588 expires_in: u64,
589}