Skip to main content

highlevel_api/auth/
refresh.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4use tokio::sync::Mutex;
5
6use super::token::TokenData;
7use crate::error::Result;
8
9/// Check if a token is expired and refresh it if needed.
10///
11/// Calls `refresh` only when `token.is_expired()` returns true. The caller is
12/// responsible for persisting the new token.
13pub async fn auto_refresh_token<F, Fut>(token: &mut TokenData, refresh: F) -> Result<()>
14where
15    F: FnOnce() -> Fut,
16    Fut: std::future::Future<Output = Result<TokenData>>,
17{
18    if !token.is_expired() {
19        return Ok(());
20    }
21    let new_token = refresh().await?;
22    *token = new_token;
23    Ok(())
24}
25
26/// Single-flight token refresher keyed by an arbitrary string (typically
27/// location ID or company ID). Ensures that concurrent callers for the same
28/// key share one refresh request, preventing refresh-token rotation conflicts.
29#[derive(Clone)]
30pub struct TokenRefresher {
31    locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
32}
33
34impl TokenRefresher {
35    pub fn new() -> Self {
36        Self {
37            locks: Arc::new(Mutex::new(HashMap::new())),
38        }
39    }
40
41    /// Acquire a per-key lock and refresh the token if expired.
42    ///
43    /// `key` is the location/company identifier. `token` is the current
44    /// `TokenData` (will be updated in place if refreshed). `refresh` is an
45    /// async callable that performs the refresh and returns a new `TokenData`.
46    pub async fn ensure_valid<F, Fut>(
47        &self,
48        key: &str,
49        token: &mut TokenData,
50        refresh: F,
51    ) -> Result<()>
52    where
53        F: FnOnce() -> Fut + Send,
54        Fut: std::future::Future<Output = Result<TokenData>> + Send,
55    {
56        // Fast path: not expired, skip locking entirely.
57        if !token.is_expired() {
58            return Ok(());
59        }
60
61        // Get or create the per-key mutex.
62        let lock = {
63            let mut map = self.locks.lock().await;
64            map.entry(key.to_string())
65                .or_insert_with(|| Arc::new(Mutex::new(())))
66                .clone()
67        };
68
69        let _guard = lock.lock().await;
70
71        // Double-check after acquiring the lock: another thread may have
72        // already refreshed.
73        if !token.is_expired() {
74            return Ok(());
75        }
76
77        let new_token = refresh().await?;
78        *token = new_token;
79        Ok(())
80    }
81}
82
83impl Default for TokenRefresher {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[tokio::test]
94    async fn unused_token_does_not_refresh() {
95        let mut token = TokenData {
96            access_token: "tok".into(),
97            refresh_token: None,
98            token_type: "Bearer".into(),
99            expires_in: 3600,
100            scope: None,
101            user_type: None,
102            company_id: None,
103            location_id: None,
104            issued_at: None,
105        };
106
107        let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
108        let replacement = token.clone();
109        let cc = call_count.clone();
110        auto_refresh_token(&mut token, move || {
111            cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
112            let t = replacement.clone();
113            async { Ok(t) }
114        })
115        .await
116        .unwrap();
117
118        assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 0);
119    }
120
121    #[tokio::test]
122    async fn expired_token_triggers_refresh() {
123        let mut token = TokenData {
124            access_token: "tok".into(),
125            refresh_token: None,
126            token_type: "Bearer".into(),
127            expires_in: 3600,
128            scope: None,
129            user_type: None,
130            company_id: None,
131            location_id: None,
132            issued_at: Some(1),
133        };
134
135        let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
136        let replacement = TokenData {
137            access_token: "new_tok".into(),
138            refresh_token: None,
139            token_type: "Bearer".into(),
140            expires_in: 3600,
141            scope: None,
142            user_type: None,
143            company_id: None,
144            location_id: None,
145            issued_at: None,
146        };
147        let cc = call_count.clone();
148        auto_refresh_token(&mut token, move || {
149            cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
150            let t = replacement.clone();
151            async { Ok(t) }
152        })
153        .await
154        .unwrap();
155
156        assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
157        assert_eq!(token.access_token, "new_tok");
158    }
159
160    #[tokio::test]
161    async fn single_flight_refreshes_once() {
162        let refresher = TokenRefresher::new();
163
164        // Shared token state across all concurrent callers.
165        let shared = Arc::new(tokio::sync::Mutex::new(TokenData {
166            access_token: "tok".into(),
167            refresh_token: None,
168            token_type: "Bearer".into(),
169            expires_in: 3600,
170            scope: None,
171            user_type: None,
172            company_id: None,
173            location_id: None,
174            issued_at: Some(1),
175        }));
176
177        let call_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
178
179        let mut tasks = vec![];
180        for _ in 0..5 {
181            let r = refresher.clone();
182            let cc = call_count.clone();
183            let shared = shared.clone();
184            tasks.push(tokio::spawn(async move {
185                let mut token = shared.lock().await;
186                r.ensure_valid("loc_1", &mut *token, || {
187                    cc.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
188                    async move {
189                        Ok(TokenData {
190                            access_token: "refreshed".into(),
191                            refresh_token: None,
192                            token_type: "Bearer".into(),
193                            expires_in: 3600,
194                            scope: None,
195                            user_type: None,
196                            company_id: None,
197                            location_id: None,
198                            issued_at: None,
199                        })
200                    }
201                })
202                .await
203                .unwrap();
204                token.access_token.clone()
205            }));
206        }
207
208        for task in tasks {
209            assert_eq!(task.await.unwrap(), "refreshed");
210        }
211        assert_eq!(call_count.load(std::sync::atomic::Ordering::SeqCst), 1);
212    }
213}