Skip to main content

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}