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}