1mod refresh_lock;
20mod refresh_transaction;
21mod resolved_store;
22mod store_lock;
23
24#[cfg(test)]
25#[path = "oauth/test_support.rs"]
26mod test_support;
27
28use anyhow::Context;
29use anyhow::Error;
30use anyhow::Result;
31use codex_config::types::AuthKeyringBackendKind;
32use codex_config::types::OAuthCredentialsStoreMode;
33use codex_secrets::LocalSecretsNamespace;
34use codex_secrets::SecretName;
35use codex_secrets::SecretScope;
36use codex_secrets::SecretsBackendKind;
37use codex_secrets::SecretsManager;
38use oauth2::AccessToken;
39use oauth2::RefreshToken;
40use oauth2::Scope;
41use oauth2::TokenResponse;
42use oauth2::basic::BasicTokenType;
43use rmcp::transport::auth::OAuthTokenResponse;
44use rmcp::transport::auth::VendorExtraTokenFields;
45use serde::Deserialize;
46use serde::Serialize;
47use serde_json::Value;
48use serde_json::map::Map as JsonMap;
49use sha2::Digest;
50use sha2::Sha256;
51use std::collections::BTreeMap;
52use std::fs;
53use std::io::ErrorKind;
54use std::path::PathBuf;
55use std::sync::Arc;
56use std::time::Duration;
57use std::time::SystemTime;
58use std::time::UNIX_EPOCH;
59use tracing::warn;
60
61use self::store_lock::OAuthStore;
62use self::store_lock::OAuthStoreLock;
63use self::store_lock::OAuthStoreLockFailure;
64
65use codex_keyring_store::DefaultKeyringStore;
66use codex_keyring_store::KeyringStore;
67use rmcp::transport::auth::AuthorizationManager;
68use tokio::sync::Mutex;
69
70use codex_utils_home_dir::find_codex_home;
71
72pub(crate) use self::resolved_store::ResolvedOAuthCredentialStore;
73pub(crate) use self::resolved_store::ResolvedOAuthTokens;
74pub(crate) use self::resolved_store::resolve_oauth_tokens_from_store_policy;
75
76const KEYRING_SERVICE: &str = "Codex MCP Credentials";
77const MCP_OAUTH_SECRET_PREFIX: &str = "MCP_OAUTH";
78const REFRESH_SKEW_MILLIS: u64 = 30_000;
79
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub struct StoredOAuthTokens {
82 pub server_name: String,
83 pub url: String,
84 pub client_id: String,
85 pub token_response: WrappedOAuthTokenResponse,
86 #[serde(default)]
87 pub expires_at: Option<u64>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct WrappedOAuthTokenResponse(pub OAuthTokenResponse);
93
94impl PartialEq for WrappedOAuthTokenResponse {
95 fn eq(&self, other: &Self) -> bool {
96 match (serde_json::to_string(self), serde_json::to_string(other)) {
97 (Ok(s1), Ok(s2)) => s1 == s2,
98 _ => false,
99 }
100 }
101}
102
103#[derive(Debug, PartialEq, Eq)]
104pub(crate) enum StoredOAuthTokenStatus {
105 Missing,
106 Usable,
107 AuthorizationRequired,
108}
109
110pub(crate) fn oauth_token_status(
111 server_name: &str,
112 url: &str,
113 store_mode: OAuthCredentialsStoreMode,
114 keyring_backend_kind: AuthKeyringBackendKind,
115) -> Result<StoredOAuthTokenStatus> {
116 let resolved = resolve_oauth_tokens_from_store_policy(
117 &DefaultKeyringStore,
118 server_name,
119 url,
120 store_mode,
121 keyring_backend_kind,
122 )?;
123 Ok(match resolved.as_ref().map(|resolved| &resolved.tokens) {
124 None => StoredOAuthTokenStatus::Missing,
125 Some(tokens) if oauth_tokens_are_usable(tokens) => StoredOAuthTokenStatus::Usable,
126 Some(_) => StoredOAuthTokenStatus::AuthorizationRequired,
127 })
128}
129
130fn oauth_tokens_are_usable(tokens: &StoredOAuthTokens) -> bool {
131 if tokens.client_id.trim().is_empty() {
132 return false;
133 }
134
135 let token_response = &tokens.token_response.0;
136 if token_needs_refresh(tokens.expires_at) {
137 return token_response
138 .refresh_token()
139 .is_some_and(|token| !token.secret().trim().is_empty());
140 }
141
142 !token_response.access_token().secret().trim().is_empty()
143}
144
145fn refresh_expires_in_from_timestamp(tokens: &mut StoredOAuthTokens) {
146 let Some(expires_at) = tokens.expires_at else {
147 return;
148 };
149
150 match expires_in_from_timestamp(expires_at) {
151 Some(seconds) => {
152 let duration = Duration::from_secs(seconds);
153 tokens.token_response.0.set_expires_in(Some(&duration));
154 }
155 None => {
156 tokens
160 .token_response
161 .0
162 .set_expires_in(Some(&Duration::ZERO));
163 }
164 }
165}
166
167fn load_oauth_tokens_from_keyring<K: KeyringStore + Clone + 'static>(
168 keyring_store: &K,
169 keyring_backend_kind: AuthKeyringBackendKind,
170 server_name: &str,
171 url: &str,
172) -> std::result::Result<Option<StoredOAuthTokens>, OAuthKeyringLoadError> {
173 match keyring_backend_kind {
174 AuthKeyringBackendKind::Direct => {
175 load_oauth_tokens_from_direct_keyring(keyring_store, server_name, url)
176 .map_err(OAuthKeyringLoadError::Backend)
177 }
178 AuthKeyringBackendKind::Secrets => {
179 load_oauth_tokens_from_secrets_keyring(keyring_store, server_name, url)
180 }
181 }
182}
183
184fn load_oauth_tokens_from_direct_keyring<K: KeyringStore>(
185 keyring_store: &K,
186 server_name: &str,
187 url: &str,
188) -> Result<Option<StoredOAuthTokens>> {
189 let key = compute_store_key(server_name, url)?;
190 match keyring_store.load(KEYRING_SERVICE, &key) {
191 Ok(Some(serialized)) => {
192 let mut tokens: StoredOAuthTokens = serde_json::from_str(&serialized)
193 .context("failed to deserialize OAuth tokens from keyring")?;
194 refresh_expires_in_from_timestamp(&mut tokens);
195 Ok(Some(tokens))
196 }
197 Ok(None) => Ok(None),
198 Err(error) => Err(Error::new(error.into_error())),
199 }
200}
201
202fn load_oauth_tokens_from_secrets_keyring<K: KeyringStore + Clone + 'static>(
203 keyring_store: &K,
204 server_name: &str,
205 url: &str,
206) -> std::result::Result<Option<StoredOAuthTokens>, OAuthKeyringLoadError> {
207 let _store_lock = OAuthStoreLock::acquire(OAuthStore::Secrets)?;
208 let codex_home = find_codex_home().map_err(anyhow::Error::from)?;
209 let manager = SecretsManager::new_with_keyring_store_and_namespace(
210 codex_home.to_path_buf(),
211 SecretsBackendKind::Local,
212 Arc::new(keyring_store.clone()),
213 LocalSecretsNamespace::McpOAuth,
214 );
215 let secret_name = compute_secret_name(server_name, url)?;
216 match manager
217 .get(&SecretScope::Global, &secret_name)
218 .context("failed to load MCP OAuth tokens from encrypted storage")?
219 {
220 Some(serialized) => {
221 let mut tokens: StoredOAuthTokens = serde_json::from_str(&serialized)
222 .context("failed to deserialize OAuth tokens from encrypted storage")?;
223 refresh_expires_in_from_timestamp(&mut tokens);
224 Ok(Some(tokens))
225 }
226 None => Ok(None),
227 }
228}
229
230#[derive(Debug, thiserror::Error)]
232enum OAuthKeyringLoadError {
233 #[error(transparent)]
235 StoreLock(#[from] OAuthStoreLockFailure),
236 #[error(transparent)]
238 Backend(#[from] anyhow::Error),
239}
240
241pub fn save_oauth_tokens(
242 server_name: &str,
243 tokens: &StoredOAuthTokens,
244 store_mode: OAuthCredentialsStoreMode,
245 keyring_backend_kind: AuthKeyringBackendKind,
246) -> Result<()> {
247 let keyring_store = DefaultKeyringStore;
248 match store_mode {
249 OAuthCredentialsStoreMode::Auto => save_oauth_tokens_with_keyring_with_fallback_to_file(
250 &keyring_store,
251 keyring_backend_kind,
252 server_name,
253 tokens,
254 ),
255 OAuthCredentialsStoreMode::File => save_oauth_tokens_to_file(tokens),
256 OAuthCredentialsStoreMode::Keyring => save_oauth_tokens_with_keyring_and_cleanup_file(
257 &keyring_store,
258 keyring_backend_kind,
259 server_name,
260 tokens,
261 ),
262 }
263}
264
265fn save_oauth_tokens_with_keyring<K: KeyringStore + Clone + 'static>(
266 keyring_store: &K,
267 keyring_backend_kind: AuthKeyringBackendKind,
268 server_name: &str,
269 tokens: &StoredOAuthTokens,
270) -> Result<()> {
271 match keyring_backend_kind {
274 AuthKeyringBackendKind::Direct => {
275 save_oauth_tokens_to_direct_keyring(keyring_store, server_name, tokens)
276 }
277 AuthKeyringBackendKind::Secrets => {
278 save_oauth_tokens_to_secrets_keyring(keyring_store, server_name, tokens)
279 }
280 }
281}
282
283fn save_oauth_tokens_to_direct_keyring<K: KeyringStore>(
284 keyring_store: &K,
285 server_name: &str,
286 tokens: &StoredOAuthTokens,
287) -> Result<()> {
288 let serialized = serde_json::to_string(tokens).context("failed to serialize OAuth tokens")?;
289
290 let key = compute_store_key(server_name, &tokens.url)?;
291 match keyring_store.save(KEYRING_SERVICE, &key, &serialized) {
292 Ok(()) => Ok(()),
293 Err(error) => {
294 let message = format!(
295 "failed to write OAuth tokens to keyring: {}",
296 error.message()
297 );
298 warn!("{message}");
299 Err(Error::new(error.into_error()).context(message))
300 }
301 }
302}
303
304fn save_oauth_tokens_to_secrets_keyring<K: KeyringStore + Clone + 'static>(
306 keyring_store: &K,
307 server_name: &str,
308 tokens: &StoredOAuthTokens,
309) -> Result<()> {
310 let serialized = serde_json::to_string(tokens).context("failed to serialize OAuth tokens")?;
311 let _store_lock = OAuthStoreLock::acquire(OAuthStore::Secrets)?;
312 save_oauth_tokens_to_secrets_keyring_with_lock_held(
313 keyring_store,
314 server_name,
315 tokens,
316 &serialized,
317 )
318}
319
320fn save_oauth_tokens_to_secrets_keyring_with_lock_held<K: KeyringStore + Clone + 'static>(
322 keyring_store: &K,
323 server_name: &str,
324 tokens: &StoredOAuthTokens,
325 serialized: &str,
326) -> Result<()> {
327 let codex_home = find_codex_home()?;
328 let manager = SecretsManager::new_with_keyring_store_and_namespace(
329 codex_home.to_path_buf(),
330 SecretsBackendKind::Local,
331 Arc::new(keyring_store.clone()),
332 LocalSecretsNamespace::McpOAuth,
333 );
334 let secret_name = compute_secret_name(server_name, &tokens.url)?;
335 manager
336 .set(&SecretScope::Global, &secret_name, serialized)
337 .context("failed to write OAuth tokens to encrypted storage")
338}
339
340fn save_oauth_tokens_with_keyring_and_cleanup_file<K: KeyringStore + Clone + 'static>(
342 keyring_store: &K,
343 keyring_backend_kind: AuthKeyringBackendKind,
344 server_name: &str,
345 tokens: &StoredOAuthTokens,
346) -> Result<()> {
347 save_oauth_tokens_with_keyring(keyring_store, keyring_backend_kind, server_name, tokens)?;
348 let key = compute_store_key(server_name, &tokens.url)?;
349 if let Err(error) = delete_oauth_tokens_from_file(&key) {
350 warn!(
351 server_name,
352 keyring_backend = ?keyring_backend_kind,
353 error = %error,
354 "failed to remove OAuth tokens from fallback storage"
355 );
356 }
357 Ok(())
358}
359
360fn save_oauth_tokens_with_keyring_with_fallback_to_file<K: KeyringStore + Clone + 'static>(
361 keyring_store: &K,
362 keyring_backend_kind: AuthKeyringBackendKind,
363 server_name: &str,
364 tokens: &StoredOAuthTokens,
365) -> Result<()> {
366 match save_oauth_tokens_with_keyring_and_cleanup_file(
367 keyring_store,
368 keyring_backend_kind,
369 server_name,
370 tokens,
371 ) {
372 Ok(()) => Ok(()),
373 Err(error) if error.downcast_ref::<OAuthStoreLockFailure>().is_some() => Err(error),
377 Err(error) => {
378 let message = error.to_string();
379 warn!("falling back to file storage for OAuth tokens: {message}");
380 save_oauth_tokens_to_file(tokens)
381 .with_context(|| format!("failed to write OAuth tokens to keyring: {message}"))
382 }
383 }
384}
385
386pub fn delete_oauth_tokens(
387 server_name: &str,
388 url: &str,
389 store_mode: OAuthCredentialsStoreMode,
390 keyring_backend_kind: AuthKeyringBackendKind,
391) -> Result<bool> {
392 let keyring_store = DefaultKeyringStore;
393 delete_oauth_tokens_from_keyring_and_file(
394 &keyring_store,
395 store_mode,
396 keyring_backend_kind,
397 server_name,
398 url,
399 )
400}
401
402fn delete_oauth_tokens_from_keyring_and_file<K: KeyringStore + Clone + 'static>(
403 keyring_store: &K,
404 store_mode: OAuthCredentialsStoreMode,
405 keyring_backend_kind: AuthKeyringBackendKind,
406 server_name: &str,
407 url: &str,
408) -> Result<bool> {
409 let key = compute_store_key(server_name, url)?;
410 let keyring_result =
411 delete_oauth_tokens_from_keyring(keyring_store, keyring_backend_kind, server_name, url);
412 let keyring_removed = match keyring_result {
413 Ok(removed) => removed,
414 Err(error) => {
415 let message = error.to_string();
416 warn!("failed to delete OAuth tokens from keyring: {message}");
417 match store_mode {
418 OAuthCredentialsStoreMode::Auto | OAuthCredentialsStoreMode::Keyring => {
419 return Err(error).context("failed to delete OAuth tokens from keyring");
420 }
421 OAuthCredentialsStoreMode::File => false,
422 }
423 }
424 };
425
426 let file_removed = delete_oauth_tokens_from_file(&key)?;
427 Ok(keyring_removed || file_removed)
428}
429
430fn delete_oauth_tokens_from_keyring<K: KeyringStore + Clone + 'static>(
431 keyring_store: &K,
432 keyring_backend_kind: AuthKeyringBackendKind,
433 server_name: &str,
434 url: &str,
435) -> Result<bool> {
436 match keyring_backend_kind {
437 AuthKeyringBackendKind::Direct => {
438 delete_oauth_tokens_from_direct_keyring(keyring_store, server_name, url)
439 }
440 AuthKeyringBackendKind::Secrets => {
441 let direct_removed =
442 delete_oauth_tokens_from_direct_keyring(keyring_store, server_name, url)?;
443 let secrets_removed =
444 delete_oauth_tokens_from_secrets_keyring(keyring_store, server_name, url)?;
445 Ok(direct_removed || secrets_removed)
446 }
447 }
448}
449
450fn delete_oauth_tokens_from_direct_keyring<K: KeyringStore>(
451 keyring_store: &K,
452 server_name: &str,
453 url: &str,
454) -> Result<bool> {
455 let key = compute_store_key(server_name, url)?;
456 keyring_store
457 .delete(KEYRING_SERVICE, &key)
458 .map_err(|error| Error::new(error.into_error()))
459}
460
461fn delete_oauth_tokens_from_secrets_keyring<K: KeyringStore + Clone + 'static>(
462 keyring_store: &K,
463 server_name: &str,
464 url: &str,
465) -> Result<bool> {
466 let _store_lock = OAuthStoreLock::acquire(OAuthStore::Secrets)?;
467 let codex_home = find_codex_home()?;
468 let manager = SecretsManager::new_with_keyring_store_and_namespace(
469 codex_home.to_path_buf(),
470 SecretsBackendKind::Local,
471 Arc::new(keyring_store.clone()),
472 LocalSecretsNamespace::McpOAuth,
473 );
474 let secret_name = compute_secret_name(server_name, url)?;
475 let secrets_removed = manager
476 .delete(&SecretScope::Global, &secret_name)
477 .context("failed to delete OAuth tokens from encrypted storage")?;
478 Ok(secrets_removed)
479}
480
481#[derive(Clone)]
482pub(crate) struct OAuthPersistor {
483 inner: Arc<OAuthPersistorInner>,
484}
485
486struct OAuthPersistorInner {
487 server_name: String,
488 url: String,
489 authorization_manager: Arc<Mutex<AuthorizationManager>>,
490 credential_store: ResolvedOAuthCredentialStore,
491 last_credentials: Mutex<Option<StoredOAuthTokens>>,
492}
493
494impl OAuthPersistor {
495 pub(crate) fn new(
496 server_name: String,
497 url: String,
498 authorization_manager: Arc<Mutex<AuthorizationManager>>,
499 credential_store: ResolvedOAuthCredentialStore,
500 initial_credentials: Option<StoredOAuthTokens>,
501 ) -> Self {
502 Self {
503 inner: Arc::new(OAuthPersistorInner {
504 server_name,
505 url,
506 authorization_manager,
507 credential_store,
508 last_credentials: Mutex::new(initial_credentials),
509 }),
510 }
511 }
512
513 #[expect(
515 clippy::await_holding_invalid_type,
516 reason = "AuthorizationManager async access must be serialized through its mutex"
517 )]
518 pub(crate) async fn persist_if_needed(&self) -> Result<()> {
519 let (client_id, maybe_credentials) = {
520 let manager = self.inner.authorization_manager.clone();
521 let guard = manager.lock().await;
522 guard.get_credentials().await
523 }?;
524
525 match maybe_credentials {
526 Some(credentials) => {
527 let mut last_credentials = self.inner.last_credentials.lock().await;
528 let new_token_response = WrappedOAuthTokenResponse(credentials.clone());
529 let same_token = last_credentials
530 .as_ref()
531 .map(|previous| previous.token_response == new_token_response)
532 .unwrap_or(false);
533 let expires_at = if same_token {
534 last_credentials
535 .as_ref()
536 .and_then(|previous| previous.expires_at)
537 } else {
538 compute_expires_at_millis(&credentials)
539 };
540 let stored = StoredOAuthTokens {
541 server_name: self.inner.server_name.clone(),
542 url: self.inner.url.clone(),
543 client_id,
544 token_response: new_token_response,
545 expires_at,
546 };
547 if last_credentials.as_ref() != Some(&stored) {
548 self.inner.credential_store.save(
549 &DefaultKeyringStore,
550 &self.inner.server_name,
551 &stored,
552 )?;
553 *last_credentials = Some(stored);
554 }
555 }
556 None => {
557 let mut last_credentials = self.inner.last_credentials.lock().await;
558 if last_credentials.take().is_some()
559 && let Err(error) = self.inner.credential_store.delete(
560 &DefaultKeyringStore,
561 &self.inner.server_name,
562 &self.inner.url,
563 )
564 {
565 warn!(
566 server_name = %self.inner.server_name,
567 error = %error,
568 "failed to remove MCP OAuth credentials from the resolved store"
569 );
570 }
571 }
572 }
573
574 Ok(())
575 }
576}
577
578const FALLBACK_FILENAME: &str = ".credentials.json";
579const MCP_SERVER_TYPE: &str = "http";
580
581type FallbackFile = BTreeMap<String, FallbackTokenEntry>;
582
583#[derive(Debug, Clone, Serialize, Deserialize)]
584struct FallbackTokenEntry {
585 server_name: String,
586 server_url: String,
587 client_id: String,
588 access_token: String,
589 #[serde(default)]
590 expires_at: Option<u64>,
591 #[serde(default)]
592 refresh_token: Option<String>,
593 #[serde(default)]
594 scopes: Vec<String>,
595}
596
597fn load_oauth_tokens_from_file(server_name: &str, url: &str) -> Result<Option<StoredOAuthTokens>> {
598 let _store_lock = OAuthStoreLock::acquire(OAuthStore::File)?;
599 let Some(store) = read_fallback_file_unlocked()? else {
600 return Ok(None);
601 };
602
603 let key = compute_store_key(server_name, url)?;
604
605 for entry in store.values() {
606 let entry_key = compute_store_key(&entry.server_name, &entry.server_url)?;
607 if entry_key != key {
608 continue;
609 }
610
611 let mut token_response = OAuthTokenResponse::new(
612 AccessToken::new(entry.access_token.clone()),
613 BasicTokenType::Bearer,
614 VendorExtraTokenFields::default(),
615 );
616
617 if let Some(refresh) = entry.refresh_token.clone() {
618 token_response.set_refresh_token(Some(RefreshToken::new(refresh)));
619 }
620
621 let scopes = entry.scopes.clone();
622 if !scopes.is_empty() {
623 token_response.set_scopes(Some(scopes.into_iter().map(Scope::new).collect()));
624 }
625
626 let mut stored = StoredOAuthTokens {
627 server_name: entry.server_name.clone(),
628 url: entry.server_url.clone(),
629 client_id: entry.client_id.clone(),
630 token_response: WrappedOAuthTokenResponse(token_response),
631 expires_at: entry.expires_at,
632 };
633 refresh_expires_in_from_timestamp(&mut stored);
634
635 return Ok(Some(stored));
636 }
637
638 Ok(None)
639}
640
641fn save_oauth_tokens_to_file(tokens: &StoredOAuthTokens) -> Result<()> {
644 let _store_lock = OAuthStoreLock::acquire(OAuthStore::File)?;
645 save_oauth_tokens_to_file_with_lock_held(tokens)
646}
647
648fn save_oauth_tokens_to_file_with_lock_held(tokens: &StoredOAuthTokens) -> Result<()> {
650 let key = compute_store_key(&tokens.server_name, &tokens.url)?;
651 let mut store = read_fallback_file_unlocked()?.unwrap_or_default();
652
653 let token_response = &tokens.token_response.0;
654 let expires_at = tokens
655 .expires_at
656 .or_else(|| compute_expires_at_millis(token_response));
657 let refresh_token = token_response
658 .refresh_token()
659 .map(|token| token.secret().to_string());
660 let scopes = token_response
661 .scopes()
662 .map(|s| s.iter().map(|s| s.to_string()).collect())
663 .unwrap_or_default();
664 let entry = FallbackTokenEntry {
665 server_name: tokens.server_name.clone(),
666 server_url: tokens.url.clone(),
667 client_id: tokens.client_id.clone(),
668 access_token: token_response.access_token().secret().to_string(),
669 expires_at,
670 refresh_token,
671 scopes,
672 };
673
674 store.insert(key, entry);
675 write_fallback_file(&store)
676}
677
678fn delete_oauth_tokens_from_file(key: &str) -> Result<bool> {
679 let _store_lock = OAuthStoreLock::acquire(OAuthStore::File)?;
680 let mut store = match read_fallback_file_unlocked()? {
681 Some(store) => store,
682 None => return Ok(false),
683 };
684
685 let removed = store.remove(key).is_some();
686
687 if removed {
688 write_fallback_file(&store)?;
689 }
690
691 Ok(removed)
692}
693
694pub(crate) fn compute_expires_at_millis(response: &OAuthTokenResponse) -> Option<u64> {
695 let expires_in = response.expires_in()?;
696 let now = SystemTime::now()
697 .duration_since(UNIX_EPOCH)
698 .unwrap_or_else(|_| Duration::from_secs(0));
699 let expiry = now.checked_add(expires_in)?;
700 let millis = expiry.as_millis();
701 if millis > u128::from(u64::MAX) {
702 Some(u64::MAX)
703 } else {
704 Some(millis as u64)
705 }
706}
707
708fn expires_in_from_timestamp(expires_at: u64) -> Option<u64> {
709 let now = SystemTime::now()
710 .duration_since(UNIX_EPOCH)
711 .unwrap_or_else(|_| Duration::from_secs(0));
712 let now_ms = now.as_millis() as u64;
713
714 if expires_at <= now_ms {
715 None
716 } else {
717 Some((expires_at - now_ms) / 1000)
718 }
719}
720
721fn token_needs_refresh(expires_at: Option<u64>) -> bool {
722 let Some(expires_at) = expires_at else {
723 return false;
724 };
725
726 let now = SystemTime::now()
727 .duration_since(UNIX_EPOCH)
728 .unwrap_or_else(|_| Duration::from_secs(0))
729 .as_millis() as u64;
730
731 now.saturating_add(REFRESH_SKEW_MILLIS) >= expires_at
732}
733
734fn compute_store_key(server_name: &str, server_url: &str) -> Result<String> {
735 let mut payload = JsonMap::new();
736 payload.insert(
737 "type".to_string(),
738 Value::String(MCP_SERVER_TYPE.to_string()),
739 );
740 payload.insert("url".to_string(), Value::String(server_url.to_string()));
741 payload.insert("headers".to_string(), Value::Object(JsonMap::new()));
742
743 let truncated = sha_256_prefix(&Value::Object(payload))?;
744 Ok(format!("{server_name}|{truncated}"))
745}
746
747fn compute_secret_name(server_name: &str, server_url: &str) -> Result<SecretName> {
754 let key = compute_store_key(server_name, server_url)?;
755 let mut hasher = Sha256::new();
756 hasher.update(key.as_bytes());
757 let digest = hasher.finalize();
758 let hex = format!("{digest:X}");
759 SecretName::new(&format!("{MCP_OAUTH_SECRET_PREFIX}_{}", &hex[..32]))
760}
761
762fn fallback_file_path() -> Result<PathBuf> {
763 Ok(find_codex_home()?.join(FALLBACK_FILENAME).to_path_buf())
764}
765
766fn read_fallback_file_unlocked() -> Result<Option<FallbackFile>> {
767 let path = fallback_file_path()?;
768 let contents = match fs::read_to_string(&path) {
769 Ok(contents) => contents,
770 Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
771 Err(err) => {
772 return Err(err).context(format!(
773 "failed to read credentials file at {}",
774 path.display()
775 ));
776 }
777 };
778
779 match serde_json::from_str::<FallbackFile>(&contents) {
780 Ok(store) => Ok(Some(store)),
781 Err(e) => Err(e).context(format!(
782 "failed to parse credentials file at {}",
783 path.display()
784 )),
785 }
786}
787
788fn write_fallback_file(store: &FallbackFile) -> Result<()> {
789 let path = fallback_file_path()?;
790
791 if store.is_empty() {
792 if path.exists() {
793 fs::remove_file(path)?;
794 }
795 return Ok(());
796 }
797
798 if let Some(parent) = path.parent() {
799 fs::create_dir_all(parent)?;
800 }
801
802 let serialized = serde_json::to_string(store)?;
803 fs::write(&path, serialized)?;
804
805 #[cfg(unix)]
806 {
807 use std::os::unix::fs::PermissionsExt;
808 let perms = fs::Permissions::from_mode(0o600);
809 fs::set_permissions(&path, perms)?;
810 }
811
812 Ok(())
813}
814
815fn sha_256_prefix(value: &Value) -> Result<String> {
816 let serialized =
817 serde_json::to_string(&value).context("failed to serialize MCP OAuth key payload")?;
818 let mut hasher = Sha256::new();
819 hasher.update(serialized.as_bytes());
820 let digest = hasher.finalize();
821 let hex = format!("{digest:x}");
822 let truncated = &hex[..16];
823 Ok(truncated.to_string())
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use anyhow::Result;
830 use codex_keyring_store::tests::MockKeyringStore;
831 use codex_secrets::compute_keyring_account;
832 use keyring::Error as KeyringError;
833 use pretty_assertions::assert_eq;
834 use std::sync::Arc;
835 #[path = "persistor_tests.rs"]
836 mod persistor_tests;
837
838 use super::test_support::TempCodexHome;
839
840 #[test]
841 fn resolve_oauth_tokens_from_store_policy_uses_keyring_when_available() -> Result<()> {
842 let _env = TempCodexHome::new();
843 let store = MockKeyringStore::default();
844 let tokens = sample_tokens();
845 let expected = tokens.clone();
846 let serialized = serde_json::to_string(&tokens)?;
847 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
848 store.save(KEYRING_SERVICE, &key, &serialized)?;
849
850 let resolved = super::resolve_oauth_tokens_from_store_policy(
851 &store,
852 &tokens.server_name,
853 &tokens.url,
854 OAuthCredentialsStoreMode::Auto,
855 AuthKeyringBackendKind::Direct,
856 )?
857 .expect("tokens should load from keyring");
858 assert_eq!(
859 resolved.store,
860 ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct)
861 );
862 assert_tokens_match_without_expiry(&resolved.tokens, &expected);
863 Ok(())
864 }
865
866 #[test]
867 fn load_oauth_tokens_falls_back_when_missing_in_keyring() -> Result<()> {
868 let _env = TempCodexHome::new();
869 let store = MockKeyringStore::default();
870 let tokens = sample_tokens();
871 let expected = tokens.clone();
872
873 super::save_oauth_tokens_to_file(&tokens)?;
874
875 let resolved = super::resolve_oauth_tokens_from_store_policy(
876 &store,
877 &tokens.server_name,
878 &tokens.url,
879 OAuthCredentialsStoreMode::Auto,
880 AuthKeyringBackendKind::Direct,
881 )?
882 .expect("tokens should load from fallback");
883 assert_eq!(resolved.store, ResolvedOAuthCredentialStore::File);
884 assert_tokens_match_without_expiry(&resolved.tokens, &expected);
885 Ok(())
886 }
887
888 #[test]
889 fn load_oauth_tokens_falls_back_when_keyring_errors() -> Result<()> {
890 let _env = TempCodexHome::new();
891 let store = MockKeyringStore::default();
892 let tokens = sample_tokens();
893 let expected = tokens.clone();
894 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
895 store.set_error(&key, KeyringError::Invalid("error".into(), "load".into()));
896
897 super::save_oauth_tokens_to_file(&tokens)?;
898
899 let resolved = super::resolve_oauth_tokens_from_store_policy(
900 &store,
901 &tokens.server_name,
902 &tokens.url,
903 OAuthCredentialsStoreMode::Auto,
904 AuthKeyringBackendKind::Direct,
905 )?
906 .expect("tokens should load from fallback");
907 assert_eq!(resolved.store, ResolvedOAuthCredentialStore::File);
908 assert_tokens_match_without_expiry(&resolved.tokens, &expected);
909 Ok(())
910 }
911
912 #[test]
913 fn exact_store_operations_do_not_adopt_or_mutate_the_other_store() -> Result<()> {
914 let _env = TempCodexHome::new();
915 let store = MockKeyringStore::default();
916 let file_tokens = sample_tokens();
917 let mut keyring_tokens = file_tokens.clone();
918 keyring_tokens
919 .token_response
920 .0
921 .set_access_token(AccessToken::new("keyring-access-token".to_string()));
922
923 super::save_oauth_tokens_to_file(&file_tokens)?;
924 let fallback_path = super::fallback_file_path()?;
925 let fallback_before = fs::read(&fallback_path)?;
926 super::save_oauth_tokens_with_keyring(
927 &store,
928 AuthKeyringBackendKind::Direct,
929 &keyring_tokens.server_name,
930 &keyring_tokens,
931 )?;
932
933 assert_eq!(fs::read(fallback_path)?, fallback_before);
934 let loaded = ResolvedOAuthCredentialStore::Keyring(AuthKeyringBackendKind::Direct)
935 .load(&store, &keyring_tokens.server_name, &keyring_tokens.url)?
936 .expect("tokens should load from the selected keyring store");
937 assert_tokens_match_without_expiry(&loaded, &keyring_tokens);
938 Ok(())
939 }
940
941 #[test]
942 fn save_oauth_tokens_prefers_keyring_when_available() -> Result<()> {
943 let _env = TempCodexHome::new();
944 let store = MockKeyringStore::default();
945 let tokens = sample_tokens();
946 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
947
948 super::save_oauth_tokens_to_file(&tokens)?;
949
950 super::save_oauth_tokens_with_keyring_with_fallback_to_file(
951 &store,
952 AuthKeyringBackendKind::Direct,
953 &tokens.server_name,
954 &tokens,
955 )?;
956
957 let fallback_path = super::fallback_file_path()?;
958 assert!(!fallback_path.exists(), "fallback file should be removed");
959 let stored = store.saved_value(&key).expect("value saved to keyring");
960 assert_eq!(serde_json::from_str::<StoredOAuthTokens>(&stored)?, tokens);
961 Ok(())
962 }
963
964 #[test]
965 fn save_oauth_tokens_writes_fallback_when_keyring_fails() -> Result<()> {
966 let _env = TempCodexHome::new();
967 let store = MockKeyringStore::default();
968 let tokens = sample_tokens();
969 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
970 store.set_error(&key, KeyringError::Invalid("error".into(), "save".into()));
971
972 super::save_oauth_tokens_with_keyring_with_fallback_to_file(
973 &store,
974 AuthKeyringBackendKind::Direct,
975 &tokens.server_name,
976 &tokens,
977 )?;
978
979 let fallback_path = super::fallback_file_path()?;
980 assert!(fallback_path.exists(), "fallback file should be created");
981 let saved = super::read_fallback_file_unlocked()?.expect("fallback file should load");
982 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
983 let entry = saved.get(&key).expect("entry for key");
984 assert_eq!(entry.server_name, tokens.server_name);
985 assert_eq!(entry.server_url, tokens.url);
986 assert_eq!(entry.client_id, tokens.client_id);
987 assert_eq!(
988 entry.access_token,
989 tokens.token_response.0.access_token().secret().as_str()
990 );
991 assert!(store.saved_value(&key).is_none());
992 Ok(())
993 }
994
995 #[test]
996 fn save_oauth_tokens_with_secrets_backend_writes_encrypted_storage() -> Result<()> {
997 let env = TempCodexHome::new();
998 let store = MockKeyringStore::default();
999 let tokens = sample_tokens();
1000 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
1001 let serialized = serde_json::to_string(&tokens)?;
1002 store.save(KEYRING_SERVICE, &key, &serialized)?;
1003 super::save_oauth_tokens_to_file(&tokens)?;
1004
1005 super::save_oauth_tokens_with_keyring_with_fallback_to_file(
1006 &store,
1007 AuthKeyringBackendKind::Secrets,
1008 &tokens.server_name,
1009 &tokens,
1010 )?;
1011
1012 let manager = SecretsManager::new_with_keyring_store_and_namespace(
1013 env.path().to_path_buf(),
1014 SecretsBackendKind::Local,
1015 Arc::new(store.clone()),
1016 LocalSecretsNamespace::McpOAuth,
1017 );
1018 let secret_name = super::compute_secret_name(&tokens.server_name, &tokens.url)?;
1019 let stored = manager
1020 .get(&SecretScope::Global, &secret_name)?
1021 .expect("tokens should be saved to encrypted storage");
1022 assert_eq!(serde_json::from_str::<StoredOAuthTokens>(&stored)?, tokens);
1023 assert_eq!(store.saved_value(&key), Some(serialized));
1024 assert!(env.path().join("secrets").join("mcp_oauth.age").exists());
1025 assert!(!env.path().join("secrets").join("local.age").exists());
1026 assert!(!super::fallback_file_path()?.exists());
1027 Ok(())
1028 }
1029
1030 #[test]
1031 fn load_oauth_tokens_with_secrets_backend_reads_encrypted_storage() -> Result<()> {
1032 let _env = TempCodexHome::new();
1033 let store = MockKeyringStore::default();
1034 let tokens = sample_tokens();
1035 let expected = tokens.clone();
1036
1037 super::save_oauth_tokens_with_keyring(
1038 &store,
1039 AuthKeyringBackendKind::Secrets,
1040 &tokens.server_name,
1041 &tokens,
1042 )?;
1043
1044 let loaded = super::load_oauth_tokens_from_keyring(
1045 &store,
1046 AuthKeyringBackendKind::Secrets,
1047 &tokens.server_name,
1048 &tokens.url,
1049 )?
1050 .expect("tokens should load from encrypted storage");
1051 assert_tokens_match_without_expiry(&loaded, &expected);
1052 Ok(())
1053 }
1054
1055 #[test]
1056 fn load_oauth_tokens_with_secrets_backend_ignores_direct_entry() -> Result<()> {
1057 let _env = TempCodexHome::new();
1058 let store = MockKeyringStore::default();
1059 let tokens = sample_tokens();
1060 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
1061 let serialized = serde_json::to_string(&tokens)?;
1062 store.save(KEYRING_SERVICE, &key, &serialized)?;
1063
1064 let loaded = super::load_oauth_tokens_from_keyring(
1065 &store,
1066 AuthKeyringBackendKind::Secrets,
1067 &tokens.server_name,
1068 &tokens.url,
1069 )?;
1070
1071 assert!(loaded.is_none());
1072 Ok(())
1073 }
1074
1075 #[test]
1076 fn save_oauth_tokens_with_secrets_backend_falls_back_to_file_when_keyring_fails() -> Result<()>
1077 {
1078 let env = TempCodexHome::new();
1079 let store = MockKeyringStore::default();
1080 store.set_error(
1081 &compute_keyring_account(env.path()),
1082 KeyringError::Invalid("error".into(), "save".into()),
1083 );
1084 let tokens = sample_tokens();
1085
1086 super::save_oauth_tokens_with_keyring_with_fallback_to_file(
1087 &store,
1088 AuthKeyringBackendKind::Secrets,
1089 &tokens.server_name,
1090 &tokens,
1091 )?;
1092
1093 let saved = super::read_fallback_file_unlocked()?.expect("fallback file should load");
1094 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
1095 assert!(saved.contains_key(&key));
1096 Ok(())
1097 }
1098
1099 #[test]
1100 fn delete_oauth_tokens_with_secrets_backend_removes_secrets_and_file() -> Result<()> {
1101 let env = TempCodexHome::new();
1102 let store = MockKeyringStore::default();
1103 let tokens = sample_tokens();
1104 let serialized = serde_json::to_string(&tokens)?;
1105 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
1106 store.save(KEYRING_SERVICE, &key, &serialized)?;
1107 super::save_oauth_tokens_with_keyring(
1108 &store,
1109 AuthKeyringBackendKind::Secrets,
1110 &tokens.server_name,
1111 &tokens,
1112 )?;
1113 store.save(KEYRING_SERVICE, &key, &serialized)?;
1114 super::save_oauth_tokens_to_file(&tokens)?;
1115
1116 let removed = super::delete_oauth_tokens_from_keyring_and_file(
1117 &store,
1118 OAuthCredentialsStoreMode::Auto,
1119 AuthKeyringBackendKind::Secrets,
1120 &tokens.server_name,
1121 &tokens.url,
1122 )?;
1123
1124 let manager = SecretsManager::new_with_keyring_store_and_namespace(
1125 env.path().to_path_buf(),
1126 SecretsBackendKind::Local,
1127 Arc::new(store.clone()),
1128 LocalSecretsNamespace::McpOAuth,
1129 );
1130 let secret_name = super::compute_secret_name(&tokens.server_name, &tokens.url)?;
1131 assert!(removed);
1132 assert!(manager.get(&SecretScope::Global, &secret_name)?.is_none());
1133 assert!(store.saved_value(&key).is_none());
1134 assert!(!super::fallback_file_path()?.exists());
1135 Ok(())
1136 }
1137
1138 #[test]
1139 fn delete_oauth_tokens_removes_all_storage() -> Result<()> {
1140 let _env = TempCodexHome::new();
1141 let store = MockKeyringStore::default();
1142 let tokens = sample_tokens();
1143 let serialized = serde_json::to_string(&tokens)?;
1144 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
1145 store.save(KEYRING_SERVICE, &key, &serialized)?;
1146 super::save_oauth_tokens_to_file(&tokens)?;
1147
1148 let removed = super::delete_oauth_tokens_from_keyring_and_file(
1149 &store,
1150 OAuthCredentialsStoreMode::Auto,
1151 AuthKeyringBackendKind::Direct,
1152 &tokens.server_name,
1153 &tokens.url,
1154 )?;
1155 assert!(removed);
1156 assert!(!store.contains(&key));
1157 assert!(!super::fallback_file_path()?.exists());
1158 Ok(())
1159 }
1160
1161 #[test]
1162 fn delete_oauth_tokens_file_mode_removes_keyring_only_entry() -> Result<()> {
1163 let _env = TempCodexHome::new();
1164 let store = MockKeyringStore::default();
1165 let tokens = sample_tokens();
1166 let serialized = serde_json::to_string(&tokens)?;
1167 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
1168 store.save(KEYRING_SERVICE, &key, &serialized)?;
1169 assert!(store.contains(&key));
1170
1171 let removed = super::delete_oauth_tokens_from_keyring_and_file(
1172 &store,
1173 OAuthCredentialsStoreMode::Auto,
1174 AuthKeyringBackendKind::Direct,
1175 &tokens.server_name,
1176 &tokens.url,
1177 )?;
1178 assert!(removed);
1179 assert!(!store.contains(&key));
1180 assert!(!super::fallback_file_path()?.exists());
1181 Ok(())
1182 }
1183
1184 #[test]
1185 fn delete_oauth_tokens_propagates_keyring_errors() -> Result<()> {
1186 let _env = TempCodexHome::new();
1187 let store = MockKeyringStore::default();
1188 let tokens = sample_tokens();
1189 let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
1190 store.set_error(&key, KeyringError::Invalid("error".into(), "delete".into()));
1191 super::save_oauth_tokens_to_file(&tokens).unwrap();
1192
1193 let result = super::delete_oauth_tokens_from_keyring_and_file(
1194 &store,
1195 OAuthCredentialsStoreMode::Auto,
1196 AuthKeyringBackendKind::Direct,
1197 &tokens.server_name,
1198 &tokens.url,
1199 );
1200 assert!(result.is_err());
1201 assert!(super::fallback_file_path().unwrap().exists());
1202 Ok(())
1203 }
1204
1205 #[test]
1206 fn refresh_expires_in_from_timestamp_restores_future_durations() {
1207 let mut tokens = sample_tokens();
1208 let expires_at = tokens.expires_at.expect("expires_at should be set");
1209
1210 tokens.token_response.0.set_expires_in(None);
1211 super::refresh_expires_in_from_timestamp(&mut tokens);
1212
1213 let actual = tokens
1214 .token_response
1215 .0
1216 .expires_in()
1217 .expect("expires_in should be restored")
1218 .as_secs();
1219 let expected = super::expires_in_from_timestamp(expires_at)
1220 .expect("expires_at should still be in the future");
1221 let diff = actual.abs_diff(expected);
1222 assert!(diff <= 1, "expires_in drift too large: diff={diff}");
1223 }
1224
1225 #[test]
1226 fn refresh_expires_in_from_timestamp_marks_expired_tokens() {
1227 let mut tokens = sample_tokens();
1228 let now = SystemTime::now()
1229 .duration_since(UNIX_EPOCH)
1230 .unwrap_or_else(|_| Duration::from_secs(0));
1231 let expired_at = now.as_millis() as u64;
1232 tokens.expires_at = Some(expired_at.saturating_sub(1000));
1233
1234 let duration = Duration::from_secs(600);
1235 tokens.token_response.0.set_expires_in(Some(&duration));
1236
1237 super::refresh_expires_in_from_timestamp(&mut tokens);
1238
1239 assert_eq!(tokens.token_response.0.expires_in(), Some(Duration::ZERO));
1240 }
1241
1242 #[test]
1243 fn oauth_tokens_are_usable_when_expiry_is_unknown() {
1244 let mut tokens = sample_tokens();
1245 tokens.expires_at = None;
1246 tokens.token_response.0.set_refresh_token(None);
1247
1248 assert!(super::oauth_tokens_are_usable(&tokens));
1249 }
1250
1251 #[test]
1252 fn oauth_tokens_are_usable_when_unexpired_without_refresh_token() {
1253 let mut tokens = sample_tokens();
1254 tokens.token_response.0.set_refresh_token(None);
1255
1256 assert!(super::oauth_tokens_are_usable(&tokens));
1257 }
1258
1259 #[test]
1260 fn oauth_tokens_are_usable_when_expired_but_refreshable() {
1261 let mut tokens = sample_tokens();
1262 tokens.expires_at = Some(0);
1263
1264 assert!(super::oauth_tokens_are_usable(&tokens));
1265 }
1266
1267 #[test]
1268 fn oauth_tokens_are_not_usable_when_expired_and_unrefreshable() {
1269 let mut tokens = sample_tokens();
1270 tokens.expires_at = Some(0);
1271 tokens.token_response.0.set_refresh_token(None);
1272
1273 assert!(!super::oauth_tokens_are_usable(&tokens));
1274 }
1275
1276 #[test]
1277 fn oauth_tokens_are_not_usable_when_near_expiry_and_unrefreshable() {
1278 let mut tokens = sample_tokens();
1279 let now = SystemTime::now()
1280 .duration_since(UNIX_EPOCH)
1281 .unwrap_or_else(|_| Duration::from_secs(0))
1282 .as_millis() as u64;
1283 tokens.expires_at = Some(now.saturating_add(REFRESH_SKEW_MILLIS - 1));
1284 tokens.token_response.0.set_refresh_token(None);
1285
1286 assert!(!super::oauth_tokens_are_usable(&tokens));
1287 }
1288
1289 #[test]
1290 fn oauth_tokens_are_not_usable_when_client_id_is_blank() {
1291 let mut tokens = sample_tokens();
1292 tokens.client_id = " ".to_string();
1293
1294 assert!(!super::oauth_tokens_are_usable(&tokens));
1295 }
1296
1297 #[test]
1298 fn oauth_tokens_are_not_usable_when_access_token_is_blank() {
1299 let mut tokens = sample_tokens();
1300 tokens
1301 .token_response
1302 .0
1303 .set_access_token(AccessToken::new(" ".to_string()));
1304
1305 assert!(!super::oauth_tokens_are_usable(&tokens));
1306 }
1307
1308 #[test]
1309 fn oauth_tokens_are_not_usable_when_required_refresh_token_is_blank() {
1310 let mut tokens = sample_tokens();
1311 tokens.expires_at = Some(0);
1312 tokens
1313 .token_response
1314 .0
1315 .set_refresh_token(Some(RefreshToken::new(" ".to_string())));
1316
1317 assert!(!super::oauth_tokens_are_usable(&tokens));
1318 }
1319
1320 fn assert_tokens_match_without_expiry(
1321 actual: &StoredOAuthTokens,
1322 expected: &StoredOAuthTokens,
1323 ) {
1324 assert_eq!(actual.server_name, expected.server_name);
1325 assert_eq!(actual.url, expected.url);
1326 assert_eq!(actual.client_id, expected.client_id);
1327 assert_eq!(actual.expires_at, expected.expires_at);
1328 assert_token_response_match_without_expiry(
1329 &actual.token_response,
1330 &expected.token_response,
1331 );
1332 }
1333
1334 fn assert_token_response_match_without_expiry(
1335 actual: &WrappedOAuthTokenResponse,
1336 expected: &WrappedOAuthTokenResponse,
1337 ) {
1338 let actual_response = &actual.0;
1339 let expected_response = &expected.0;
1340
1341 assert_eq!(
1342 actual_response.access_token().secret(),
1343 expected_response.access_token().secret()
1344 );
1345 assert_eq!(actual_response.token_type(), expected_response.token_type());
1346 assert_eq!(
1347 actual_response.refresh_token().map(RefreshToken::secret),
1348 expected_response.refresh_token().map(RefreshToken::secret),
1349 );
1350 assert_eq!(actual_response.scopes(), expected_response.scopes());
1351 assert_eq!(
1352 actual_response.extra_fields().0,
1353 expected_response.extra_fields().0
1354 );
1355 assert_eq!(
1356 actual_response.expires_in().is_some(),
1357 expected_response.expires_in().is_some()
1358 );
1359 }
1360
1361 fn sample_tokens() -> StoredOAuthTokens {
1362 let mut response = OAuthTokenResponse::new(
1363 AccessToken::new("access-token".to_string()),
1364 BasicTokenType::Bearer,
1365 VendorExtraTokenFields::default(),
1366 );
1367 response.set_refresh_token(Some(RefreshToken::new("refresh-token".to_string())));
1368 response.set_scopes(Some(vec![
1369 Scope::new("scope-a".to_string()),
1370 Scope::new("scope-b".to_string()),
1371 ]));
1372 let expires_in = Duration::from_secs(3600);
1373 response.set_expires_in(Some(&expires_in));
1374 let expires_at = super::compute_expires_at_millis(&response);
1375
1376 StoredOAuthTokens {
1377 server_name: "test-server".to_string(),
1378 url: "https://example.test".to_string(),
1379 client_id: "client-id".to_string(),
1380 token_response: WrappedOAuthTokenResponse(response),
1381 expires_at,
1382 }
1383 }
1384}