dceapi_rs/
token.rs

1//! Token manager for DCE API authentication.
2//!
3//! Handles automatic token acquisition and refresh with thread-safe caching.
4
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use reqwest::Client as HttpClient;
9use serde::Serialize;
10use tokio::sync::RwLock;
11
12use crate::error::{Error, ErrorCode, Result};
13use crate::models::{ApiResponse, TokenResponse};
14
15/// Token expiry time in seconds (default 1 hour).
16pub const TOKEN_EXPIRY_SECONDS: u64 = 3600;
17
18/// Token expiry buffer in seconds (refresh 60s before expiry).
19pub const TOKEN_EXPIRY_BUFFER: u64 = 60;
20
21/// Authentication endpoint path.
22pub const AUTH_ENDPOINT: &str = "/dceapi/cms/auth/accessToken";
23
24/// Internal token state.
25#[derive(Debug, Default)]
26struct TokenState {
27    /// The access token.
28    token: String,
29    /// When the token expires.
30    expires_at: Option<Instant>,
31}
32
33/// Request body for authentication.
34#[derive(Debug, Serialize)]
35struct AuthRequest {
36    secret: String,
37}
38
39/// Token manager for handling authentication.
40///
41/// This struct manages the access token lifecycle:
42/// - Acquires new tokens when needed
43/// - Caches tokens to avoid unnecessary requests
44/// - Automatically refreshes tokens before expiry
45///
46/// Thread-safe: Uses `RwLock` for concurrent access.
47#[derive(Debug)]
48pub struct TokenManager {
49    api_key: String,
50    secret: String,
51    base_url: String,
52    http_client: HttpClient,
53    state: Arc<RwLock<TokenState>>,
54}
55
56impl TokenManager {
57    /// Create a new token manager.
58    pub fn new(
59        api_key: impl Into<String>,
60        secret: impl Into<String>,
61        base_url: impl Into<String>,
62        http_client: HttpClient,
63    ) -> Self {
64        TokenManager {
65            api_key: api_key.into(),
66            secret: secret.into(),
67            base_url: base_url.into(),
68            http_client,
69            state: Arc::new(RwLock::new(TokenState::default())),
70        }
71    }
72
73    /// Get a valid access token.
74    ///
75    /// Returns a cached token if still valid, otherwise acquires a new one.
76    pub async fn token(&self) -> Result<String> {
77        // Try to get cached token with read lock
78        {
79            let state = self.state.read().await;
80            if !state.token.is_empty() && !self.is_expired_locked(&state) {
81                return Ok(state.token.clone());
82            }
83        }
84
85        // Need to refresh - acquire write lock
86        self.refresh_and_get_token().await
87    }
88
89    /// Force refresh the token.
90    pub async fn refresh(&self) -> Result<()> {
91        let mut state = self.state.write().await;
92        self.refresh_locked(&mut state).await
93    }
94
95    /// Refresh and return the new token.
96    async fn refresh_and_get_token(&self) -> Result<String> {
97        let mut state = self.state.write().await;
98
99        // Double-check after acquiring write lock
100        if !state.token.is_empty() && !self.is_expired_locked(&state) {
101            return Ok(state.token.clone());
102        }
103
104        self.refresh_locked(&mut state).await?;
105        Ok(state.token.clone())
106    }
107
108    /// Internal refresh method (must hold write lock).
109    async fn refresh_locked(&self, state: &mut TokenState) -> Result<()> {
110        let auth_url = format!("{}{}", self.base_url, AUTH_ENDPOINT);
111
112        let req_body = AuthRequest {
113            secret: self.secret.clone(),
114        };
115
116        let response = self
117            .http_client
118            .post(&auth_url)
119            .header("Content-Type", "application/json")
120            .header("apikey", &self.api_key)
121            .json(&req_body)
122            .send()
123            .await
124            .map_err(|e| Error::auth(format!("failed to send auth request: {}", e)))?;
125
126        let resp_text = response
127            .text()
128            .await
129            .map_err(|e| Error::auth(format!("failed to read auth response: {}", e)))?;
130
131        let api_resp: ApiResponse = serde_json::from_str(&resp_text)
132            .map_err(|e| Error::auth(format!("failed to parse auth response: {}, body: {}", e, resp_text)))?;
133
134        if api_resp.code != ErrorCode::Success as i32 {
135            return Err(self.handle_auth_error(api_resp.code, &api_resp.msg));
136        }
137
138        let token_resp: TokenResponse = serde_json::from_value(api_resp.data)
139            .map_err(|e| Error::auth(format!("failed to parse token data: {}", e)))?;
140
141        if token_resp.access_token.is_empty() {
142            return Err(Error::auth("received empty access token"));
143        }
144
145        // Update state
146        state.token = token_resp.access_token;
147        let expires_in = if token_resp.expires_in > 0 {
148            token_resp.expires_in as u64
149        } else {
150            TOKEN_EXPIRY_SECONDS
151        };
152        // Subtract buffer to refresh before actual expiry
153        let effective_expiry = expires_in.saturating_sub(TOKEN_EXPIRY_BUFFER);
154        state.expires_at = Some(Instant::now() + Duration::from_secs(effective_expiry));
155
156        Ok(())
157    }
158
159    /// Handle authentication error and return appropriate error type.
160    fn handle_auth_error(&self, code: i32, message: &str) -> Error {
161        match ErrorCode::from_code(code) {
162            Some(ErrorCode::ParamError) => Error::auth(format!("invalid parameters: {}", message)),
163            Some(ErrorCode::NoPermission) => Error::auth(format!("permission denied: {}", message)),
164            Some(ErrorCode::ServerError) => Error::auth(format!("server error: {}", message)),
165            Some(ErrorCode::RateLimit) => Error::auth(format!("rate limited: {}", message)),
166            _ => Error::auth(format!("authentication failed (code {}): {}", code, message)),
167        }
168    }
169
170    /// Check if token is expired (must hold lock).
171    fn is_expired_locked(&self, state: &TokenState) -> bool {
172        if state.token.is_empty() {
173            return true;
174        }
175        match state.expires_at {
176            Some(expires_at) => Instant::now() >= expires_at,
177            None => true,
178        }
179    }
180
181    /// Check if the cached token is expired.
182    pub async fn is_expired(&self) -> bool {
183        let state = self.state.read().await;
184        self.is_expired_locked(&state)
185    }
186
187    /// Clear the cached token.
188    pub async fn clear_token(&self) {
189        let mut state = self.state.write().await;
190        state.token.clear();
191        state.expires_at = None;
192    }
193
194    /// Get the cached token without triggering refresh.
195    pub async fn get_cached_token(&self) -> String {
196        let state = self.state.read().await;
197        state.token.clone()
198    }
199}