Skip to main content

oxicode/
oauth_refresh.rs

1//! Per-provider OAuth refresh coalesce.
2//!
3//! [`refresh_if_expired`] looks up the stored OAuth credential for a provider
4//! and, if it is expired (or within 60 s of expiry), refreshes it via
5//! [`provider_oauth::refresh_grant`]. Concurrent callers for the same
6//! provider share a single in-flight refresh — once one finishes, all
7//! subsequent callers see the freshly stored token without making a
8//! second network request.
9//!
10//! ## Storage injection
11//!
12//! The public entry point reads from the process-wide [`shared_auth_storage`]
13//! singleton. The inner [`refresh_if_expired_with_storage`] helper accepts an
14//! explicit `&Arc<AuthStorage>` so tests can drive an in-memory storage
15//! without touching the real `auth.json`. This mirrors the controller's
16//! pre-flight API-alignment note: callers must NOT assume a method like
17//! `auth.get_api_key_full` exists — we read the full credential set via
18//! `auth.get_all()` and pattern-match the OAuth variant.
19
20use crate::provider_oauth;
21use crate::store::auth_storage::{AuthCredential, AuthStorage, shared_auth_storage};
22use std::collections::HashMap;
23use std::sync::{Arc, LazyLock};
24use tokio::sync::Mutex;
25
26#[derive(Debug, Clone, thiserror::Error)]
27pub enum RefreshError {
28    /// The provider has no stored OAuth credential at all (only an API
29    /// key, no entry, or a session token).
30    #[error("no OAuth credential for '{0}'")]
31    NotOAuth(String),
32
33    /// The stored OAuth credential has no `refresh_token`; the user must
34    /// re-run the interactive login flow to obtain one.
35    #[error("refresh token missing — re-login required for '{0}'")]
36    ReLoginRequired(String),
37
38    /// The provider has an OAuth spec but the actual refresh grant failed
39    /// (network error, provider rejection, malformed response, etc.).
40    #[error("refresh failed: {0}")]
41    Failed(String),
42}
43
44/// One coalesce cell. Concurrent callers for the same provider share a
45/// single cell so only the first one performs the actual refresh grant.
46type CoalesceCell = Arc<tokio::sync::OnceCell<Result<(), RefreshError>>>;
47
48/// Process-wide coalesce map: provider name → in-flight refresh future.
49///
50/// `tokio::sync::OnceCell<Result<(), RefreshError>>` is used so that only the first caller for a
51/// given provider actually performs the network round-trip; concurrent
52/// callers wait on the same cell and observe the final `Result` after the
53/// refresh completes (success or failure). The map itself is `Mutex`-ed so
54/// we can register cells concurrently without races.
55static COALESCE: LazyLock<Mutex<HashMap<String, CoalesceCell>>> =
56    LazyLock::new(|| Mutex::new(HashMap::new()));
57
58/// Refresh the stored OAuth credential for `provider` if it is expired or
59/// within 60 seconds of expiry. Concurrent calls coalesce on a per-provider
60/// `OnceCell` so the network round-trip runs at most once per cycle.
61///
62/// Returns `Ok(())` immediately when the credential is unexpired or
63/// "never-expiring" (`expires_at == 0`) credentials; returns
64/// [`RefreshError::NotOAuth`] when the provider has no OAuth entry at all
65/// so the caller can distinguish "nothing to do" from "did a refresh".
66/// Storage is read from the process-wide [`shared_auth_storage`] singleton.
67pub async fn refresh_if_expired(provider: &str) -> Result<(), RefreshError> {
68    let auth = shared_auth_storage();
69    refresh_if_expired_with_storage(provider, &auth).await
70}
71
72/// Inner helper: refresh against an explicit `AuthStorage`.
73///
74/// Separating the storage parameter out of [`refresh_if_expired`] lets tests
75/// inject an in-memory storage without polluting the user's real `auth.json`.
76pub async fn refresh_if_expired_with_storage(
77    provider: &str,
78    auth: &Arc<AuthStorage>,
79) -> Result<(), RefreshError> {
80    let creds = auth.get_all();
81    let credential = creds
82        .get(provider)
83        .ok_or_else(|| RefreshError::NotOAuth(provider.to_string()))?;
84
85    let (refresh_token, spec) = match credential {
86        AuthCredential::OAuth {
87            access_token: _,
88            refresh_token,
89            expires_at,
90            scopes: _,
91            provider_data: _,
92        } => {
93            // `expires_at == 0` ⇒ never-expiring (matches `is_expired()` in
94            // auth_storage.rs). Treat as not-expired and skip the refresh.
95            if *expires_at == 0 {
96                return Ok(());
97            }
98            let now = chrono::Utc::now().timestamp().max(0) as u64;
99            // `needs_refresh()` in auth_storage.rs uses `expires_at <= now + 60`.
100            if *expires_at > now + 60 {
101                return Ok(());
102            }
103            let rt = refresh_token
104                .clone()
105                .ok_or_else(|| RefreshError::ReLoginRequired(provider.to_string()))?;
106            let spec = provider_oauth::spec_for(provider)
107                .ok_or_else(|| RefreshError::Failed(format!("no OAuth spec for {provider}")))?;
108            (rt, spec)
109        }
110        // ApiKey or Session — not OAuth.
111        _ => return Err(RefreshError::NotOAuth(provider.to_string())),
112    };
113
114    // Get or insert the coalesce cell for this provider. Two callers may
115    // race here; the Mutex serializes the map mutation, after which both
116    // hold the same Arc<OnceCell<()>>.
117    let cell = {
118        let mut map = COALESCE.lock().await;
119        map.entry(provider.to_string())
120            .or_insert_with(|| Arc::new(tokio::sync::OnceCell::new()))
121            .clone()
122    };
123
124    // Run the refresh exactly once per coalesce cell. Any error is
125    // captured inside the cell so all waiters observe the same outcome;
126    // the next caller (after the cell resolves) will simply re-check
127    // expiry and may issue a fresh refresh if the previous attempt failed.
128    // `do_refresh` returns `anyhow::Result<()>`. Wrap it into
129    // `Result<(), RefreshError>` once, so every coalesced caller observes
130    // the same `RefreshError::Failed` message.
131    let cell_value = cell.get_or_init(|| async {
132        do_refresh(provider, &spec, &refresh_token)
133            .await
134            .map_err(|e| RefreshError::Failed(format!("{e}")))
135    });
136    // `OnceCell::get_or_init(...).await` returns `&Result<...>`. The
137    // `?` operator only works on owned `Result`s, so we clone the inner
138    // `RefreshError` (`Clone`-derived via `thiserror`) and return.
139    let outcome: Result<(), RefreshError> = match cell_value.await {
140        Ok(()) => Ok(()),
141        Err(e) => Err(e.clone()),
142    };
143    outcome?;
144    Ok(())
145}
146
147/// Drop the coalesce cell for `provider` so the next call to
148/// [`refresh_if_expired`] is forced to do a fresh refresh. Intended for
149/// tests and for error-recovery paths that want to invalidate a failed
150/// cell so a retry actually retries instead of returning the cached
151/// `RefreshError::Failed`.
152pub async fn invalidate_coalesce(provider: &str) {
153    let mut map = COALESCE.lock().await;
154    map.remove(provider);
155}
156
157async fn do_refresh(
158    provider: &str,
159    spec: &provider_oauth::ProviderOAuthSpec,
160    refresh_token: &str,
161) -> anyhow::Result<()> {
162    let tokens = provider_oauth::refresh_grant(spec, refresh_token).await?;
163    let auth = shared_auth_storage();
164    // `update_oauth_tokens` takes `u64` for `new_expires_at`; convert from
165    // the i64 timestamp returned by `refresh_grant` (which is what
166    // `provider_oauth` exposes so callers can branch on `now < expires_at`
167    // in i64). A negative `expires_at` is impossible here (it's
168    // `now + expires_in` with `expires_in >= 0`), but saturate defensively.
169    let new_expires_at: u64 = tokens.expires_at.max(0) as u64;
170    auth.update_oauth_tokens(
171        provider,
172        tokens.access_token,
173        tokens.refresh_token,
174        new_expires_at,
175    )
176    .map_err(|e| anyhow::anyhow!("{e}"))?;
177    Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::store::auth_storage::{AuthCredential, AuthStorage};
184
185    /// A credential whose expiry is comfortably in the future must NOT
186    /// trigger a network call. This is the failing-test gate for TDD:
187    /// before `refresh_if_expired_with_storage` is implemented, the test
188    /// cannot even compile, satisfying the brief's step 4 expectation.
189    #[tokio::test]
190    async fn refresh_if_expired_noop_when_not_expired() {
191        let auth = Arc::new(AuthStorage::in_memory());
192        let now = chrono::Utc::now().timestamp() as u64;
193        // 1 hour in the future — well outside the 60 s refresh window.
194        auth.set_oauth_full(
195            "test-provider",
196            "AT-unexpired".to_string(),
197            Some("RT-unexpired".to_string()),
198            now + 3600,
199            None,
200            None,
201        );
202
203        // No MockServer is started: if `refresh_if_expired_with_storage`
204        // attempted to refresh, this test would hang on a connect-refused
205        // against `spec_for("test-provider")`'s real token_url (or panic
206        // on `spec_for` returning `None` → `RefreshError::Failed`). Either
207        // way, a clean Ok(()) is the proof we skipped the refresh.
208        let result = refresh_if_expired_with_storage("test-provider", &auth).await;
209        assert!(
210            result.is_ok(),
211            "unexpired credential should be a no-op, got {result:?}"
212        );
213
214        // The credential must be unchanged (same access token).
215        let stored = auth
216            .get_all()
217            .remove("test-provider")
218            .expect("credential still present");
219        match stored {
220            AuthCredential::OAuth { access_token, .. } => {
221                assert_eq!(access_token, "AT-unexpired");
222            }
223            other => panic!("expected OAuth credential, got {other:?}"),
224        }
225    }
226
227    /// `expires_at == 0` means "never expires"; refresh must be skipped.
228    #[tokio::test]
229    async fn refresh_if_expired_noop_when_expires_at_zero() {
230        let auth = Arc::new(AuthStorage::in_memory());
231        auth.set_oauth_full(
232            "never-exp",
233            "AT-never".to_string(),
234            Some("RT-never".to_string()),
235            0, // never-expiring sentinel
236            None,
237            None,
238        );
239
240        let result = refresh_if_expired_with_storage("never-exp", &auth).await;
241        assert!(
242            result.is_ok(),
243            "expires_at=0 must be a no-op, got {result:?}"
244        );
245    }
246
247    /// No stored credential at all → `RefreshError::NotOAuth`.
248    #[tokio::test]
249    async fn refresh_if_expired_returns_not_oauth_when_missing() {
250        let auth = Arc::new(AuthStorage::in_memory());
251        let result = refresh_if_expired_with_storage("ghost", &auth).await;
252        match result {
253            Err(RefreshError::NotOAuth(name)) => assert_eq!(name, "ghost"),
254            other => panic!("expected NotOAuth(\"ghost\"), got {other:?}"),
255        }
256    }
257
258    /// API-key credential (not OAuth) → `RefreshError::NotOAuth`. Mirrors
259    /// the existing `is_expired` / `needs_refresh` helpers in
260    /// `auth_storage.rs` which also short-circuit on non-OAuth.
261    #[tokio::test]
262    async fn refresh_if_expired_returns_not_oauth_for_api_key() {
263        let auth = Arc::new(AuthStorage::in_memory());
264        auth.set_api_key("anthropic", "sk-test".to_string());
265        let result = refresh_if_expired_with_storage("anthropic", &auth).await;
266        match result {
267            Err(RefreshError::NotOAuth(name)) => assert_eq!(name, "anthropic"),
268            other => panic!("expected NotOAuth, got {other:?}"),
269        }
270    }
271
272    /// OAuth credential without a refresh token → `RefreshError::ReLoginRequired`.
273    #[tokio::test]
274    async fn refresh_if_expired_returns_re_login_when_no_refresh_token() {
275        let auth = Arc::new(AuthStorage::in_memory());
276        // Spec exists for "openai" via the embedded catalog; we set an
277        // expired credential with no refresh_token. The expiry gate fires
278        // first; we then look for the refresh_token and find None.
279        let now = chrono::Utc::now().timestamp() as u64;
280        auth.set_oauth_full(
281            "openai",
282            "AT-stale".to_string(),
283            None,
284            now.saturating_sub(10), // already expired
285            None,
286            None,
287        );
288        let result = refresh_if_expired_with_storage("openai", &auth).await;
289        match result {
290            Err(RefreshError::ReLoginRequired(name)) => assert_eq!(name, "openai"),
291            other => panic!("expected ReLoginRequired, got {other:?}"),
292        }
293    }
294}