Skip to main content

nodedb_lite/sync/client/
token.rs

1//! JWT token refresh (proactive at 80% lifetime, reactive on auth failure).
2//!
3//! On a successful refresh both `token_refresh_pending` and
4//! `push_paused_for_auth` are cleared.  On failure `push_paused_for_auth`
5//! remains set (push stays paused) and an exponential backoff interval is
6//! applied before the next refresh attempt is permitted.  The provider call
7//! itself is wrapped in a 30-second timeout so a hung provider cannot block
8//! the sync task indefinitely.
9
10use nodedb_types::sync::wire::{TokenRefreshAckMsg, TokenRefreshMsg};
11
12use super::state::SyncClient;
13
14/// Minimum interval (ms) between consecutive token refresh attempts after failure.
15pub const TOKEN_REFRESH_MIN_BACKOFF_MS: u64 = 5_000; // 5 s
16
17/// Maximum backoff interval (ms) between refresh attempts.
18const TOKEN_REFRESH_MAX_BACKOFF_MS: u64 = 300_000; // 5 min
19
20/// Timeout for a single provider() call.
21const TOKEN_PROVIDER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
22
23impl SyncClient {
24    /// Check if the JWT token needs proactive refresh (at 80% of lifetime).
25    ///
26    /// Returns `true` if a refresh should be initiated. Called from the
27    /// ping loop to piggyback on the keepalive timer.
28    pub async fn should_refresh_token(&self) -> bool {
29        if self.config.token_provider.is_none() || self.config.token_lifetime_secs == 0 {
30            return false;
31        }
32        if *self.token_refresh_pending.lock().await {
33            return false;
34        }
35        let set_at = *self.token_set_at_ms.lock().await;
36        let now = crate::runtime::now_millis();
37        let elapsed_ms = now.saturating_sub(set_at);
38        let threshold_ms = self.config.token_lifetime_secs * 800; // 80% of lifetime
39        elapsed_ms >= threshold_ms
40    }
41
42    /// Check if a token refresh attempt is currently allowed given the backoff.
43    ///
44    /// Returns `false` if the minimum retry interval since the last attempt has
45    /// not elapsed yet. Called by `push_control_messages` before invoking
46    /// `initiate_token_refresh`.
47    pub async fn is_refresh_backoff_elapsed(&self) -> bool {
48        let last = *self.token_last_attempt_ms.lock().await;
49        if last == 0 {
50            return true;
51        }
52        let backoff = *self.token_refresh_backoff_ms.lock().await;
53        let now = crate::runtime::now_millis();
54        now.saturating_sub(last) >= backoff
55    }
56
57    /// Initiate a token refresh via the token provider.
58    ///
59    /// Marks a refresh as in-flight, calls the provider with a 30-second
60    /// timeout, and returns the new token message on success.  On failure
61    /// (provider returns `None` or times out) the `token_refresh_pending` flag
62    /// is cleared, `push_paused_for_auth` is kept `true` (push remains paused),
63    /// and the backoff doubles for the next attempt.
64    pub async fn initiate_token_refresh(&self) -> Option<TokenRefreshMsg> {
65        let provider = self.config.token_provider.as_ref()?;
66
67        // Record that we are starting an attempt now.
68        *self.token_last_attempt_ms.lock().await = crate::runtime::now_millis();
69        *self.token_refresh_pending.lock().await = true;
70
71        tracing::info!("initiating JWT token refresh");
72
73        let fut = provider();
74        let result = tokio::time::timeout(TOKEN_PROVIDER_TIMEOUT, fut).await;
75
76        match result {
77            Ok(Some(new_token)) => {
78                // Success — backoff resets to minimum for the next cycle.
79                *self.token_refresh_backoff_ms.lock().await = TOKEN_REFRESH_MIN_BACKOFF_MS;
80                Some(TokenRefreshMsg { new_token })
81            }
82            Ok(None) => {
83                // Provider signalled failure (returned None).
84                tracing::warn!("token provider returned None; keeping push paused");
85                self.on_refresh_failure().await;
86                None
87            }
88            Err(_elapsed) => {
89                // Provider call timed out.
90                tracing::warn!(
91                    timeout_secs = TOKEN_PROVIDER_TIMEOUT.as_secs(),
92                    "token provider timed out; keeping push paused"
93                );
94                self.on_refresh_failure().await;
95                None
96            }
97        }
98    }
99
100    /// Handle a TokenRefreshAck from Origin.
101    pub async fn handle_token_refresh_ack(&self, ack: &TokenRefreshAckMsg) {
102        *self.token_refresh_pending.lock().await = false;
103
104        if ack.success {
105            // Full success: clear auth pause and reset backoff.
106            *self.token_set_at_ms.lock().await = crate::runtime::now_millis();
107            *self.push_paused_for_auth.lock().await = false;
108            *self.token_refresh_backoff_ms.lock().await = TOKEN_REFRESH_MIN_BACKOFF_MS;
109            tracing::info!(
110                expires_in_secs = ack.expires_in_secs,
111                "JWT token refresh accepted by Origin"
112            );
113        } else {
114            // Origin rejected the new token — stay paused and back off.
115            tracing::warn!(
116                error = ack.error.as_deref().unwrap_or("unknown"),
117                "JWT token refresh rejected by Origin; keeping push paused"
118            );
119            self.apply_backoff().await;
120        }
121    }
122
123    /// Pause delta push due to auth failure. Called when Origin rejects
124    /// with PermissionDenied, indicating the token has expired.
125    pub async fn pause_for_auth(&self) {
126        *self.push_paused_for_auth.lock().await = true;
127        tracing::warn!("delta push paused — auth failure, awaiting token refresh");
128    }
129
130    /// Check if push is paused for auth.
131    pub async fn is_push_paused_for_auth(&self) -> bool {
132        *self.push_paused_for_auth.lock().await
133    }
134
135    // ── Internal helpers ──────────────────────────────────────────────────────
136
137    /// Called when a token refresh attempt fails (provider error or timeout).
138    ///
139    /// Clears `token_refresh_pending` so the next ping-loop tick can retry,
140    /// but keeps `push_paused_for_auth = true` so no deltas are sent.
141    /// Doubles the backoff for the next attempt.
142    async fn on_refresh_failure(&self) {
143        *self.token_refresh_pending.lock().await = false;
144        // push_paused_for_auth stays true — push remains paused until a
145        // successful refresh+ack cycle clears it in handle_token_refresh_ack.
146        self.apply_backoff().await;
147    }
148
149    /// Double the backoff, capped at `TOKEN_REFRESH_MAX_BACKOFF_MS`.
150    async fn apply_backoff(&self) {
151        let mut backoff = self.token_refresh_backoff_ms.lock().await;
152        *backoff = (*backoff * 2).min(TOKEN_REFRESH_MAX_BACKOFF_MS);
153        tracing::debug!(
154            next_backoff_ms = *backoff,
155            "token refresh backoff increased"
156        );
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::sync::client::SyncConfig;
164    use std::sync::Arc;
165
166    fn make_config_with_provider(returns: Option<&'static str>) -> SyncConfig {
167        let provider: crate::sync::client::config::TokenProvider =
168            Arc::new(move || Box::pin(async move { returns.map(|s| s.to_string()) }));
169        SyncConfig::new("wss://localhost:9090/sync", "test.jwt.token")
170            .with_token_provider(provider, 3600)
171    }
172
173    #[tokio::test]
174    async fn refresh_failure_keeps_push_paused() {
175        let config = make_config_with_provider(None); // provider always fails
176        let client = SyncClient::new(config);
177        client.pause_for_auth().await;
178
179        // Attempt a refresh — provider returns None.
180        let result = client.initiate_token_refresh().await;
181        assert!(result.is_none());
182
183        // Push must still be paused.
184        assert!(client.is_push_paused_for_auth().await);
185        // token_refresh_pending must be cleared so the next tick can retry.
186        assert!(!*client.token_refresh_pending.lock().await);
187    }
188
189    #[tokio::test]
190    async fn refresh_failure_applies_backoff() {
191        let config = make_config_with_provider(None);
192        let client = SyncClient::new(config);
193        client.pause_for_auth().await;
194
195        let initial_backoff = *client.token_refresh_backoff_ms.lock().await;
196
197        client.initiate_token_refresh().await;
198        let after_first = *client.token_refresh_backoff_ms.lock().await;
199        assert_eq!(
200            after_first,
201            (initial_backoff * 2).min(TOKEN_REFRESH_MAX_BACKOFF_MS)
202        );
203
204        client.initiate_token_refresh().await;
205        let after_second = *client.token_refresh_backoff_ms.lock().await;
206        assert_eq!(
207            after_second,
208            (after_first * 2).min(TOKEN_REFRESH_MAX_BACKOFF_MS)
209        );
210    }
211
212    #[tokio::test]
213    async fn refresh_success_clears_pause_and_resets_backoff() {
214        let config = make_config_with_provider(Some("fresh-jwt-token"));
215        let client = SyncClient::new(config);
216        client.pause_for_auth().await;
217
218        // Drive backoff up.
219        *client.token_refresh_backoff_ms.lock().await = 60_000;
220
221        let msg = client.initiate_token_refresh().await;
222        assert!(msg.is_some());
223        assert_eq!(msg.unwrap().new_token, "fresh-jwt-token");
224
225        // Simulate a successful TokenRefreshAck from Origin.
226        client
227            .handle_token_refresh_ack(&TokenRefreshAckMsg {
228                success: true,
229                expires_in_secs: 3600,
230                error: None,
231            })
232            .await;
233
234        assert!(!client.is_push_paused_for_auth().await);
235        assert_eq!(
236            *client.token_refresh_backoff_ms.lock().await,
237            TOKEN_REFRESH_MIN_BACKOFF_MS
238        );
239    }
240
241    #[tokio::test]
242    async fn backoff_enforced_between_attempts() {
243        let config = make_config_with_provider(None);
244        let client = SyncClient::new(config);
245
246        // Simulate a failed attempt just now.
247        *client.token_last_attempt_ms.lock().await = crate::runtime::now_millis();
248        *client.token_refresh_backoff_ms.lock().await = TOKEN_REFRESH_MIN_BACKOFF_MS;
249
250        // Backoff not elapsed — should not be allowed.
251        assert!(!client.is_refresh_backoff_elapsed().await);
252
253        // Simulate a very old last attempt.
254        *client.token_last_attempt_ms.lock().await = 0;
255        assert!(client.is_refresh_backoff_elapsed().await);
256    }
257
258    #[tokio::test]
259    async fn backoff_capped_at_max() {
260        let config = make_config_with_provider(None);
261        let client = SyncClient::new(config);
262
263        // Start near the cap.
264        *client.token_refresh_backoff_ms.lock().await = TOKEN_REFRESH_MAX_BACKOFF_MS / 2 + 1;
265        client.apply_backoff().await;
266        assert_eq!(
267            *client.token_refresh_backoff_ms.lock().await,
268            TOKEN_REFRESH_MAX_BACKOFF_MS
269        );
270
271        // Another doubling stays at cap.
272        client.apply_backoff().await;
273        assert_eq!(
274            *client.token_refresh_backoff_ms.lock().await,
275            TOKEN_REFRESH_MAX_BACKOFF_MS
276        );
277    }
278}