Skip to main content

codex_rmcp_client/
oauth.rs

1//! This file handles all logic related to managing MCP OAuth credentials.
2//! All credentials are stored using the keyring crate which uses os-specific keyring services.
3//! https://crates.io/crates/keyring
4//! macOS: macOS keychain.
5//! Windows: Windows Credential Manager
6//! Linux: DBus-based Secret Service, the kernel keyutils, and a combo of the two
7//! FreeBSD, OpenBSD: DBus-based Secret Service
8//!
9//! For Linux, we use linux-native-async-persistent which uses both keyutils and async-secret-service (see below) for storage.
10//! See the docs for the keyutils_persistent module for a full explanation of why both are used. Because this store uses the
11//! async-secret-service, you must specify the additional features required by that store
12//!
13//! async-secret-service provides access to the DBus-based Secret Service storage on Linux, FreeBSD, and OpenBSD. This is an asynchronous
14//! keystore that always encrypts secrets when they are transferred across the bus. If DBus isn't installed the keystore will fall back to the json
15//! file because we don't use the "vendored" feature.
16//!
17//! If the keyring is not available or fails, we fall back to CODEX_HOME/.credentials.json which is consistent with other coding CLI agents.
18
19mod 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/// Wrap OAuthTokenResponse to allow for partial equality comparison.
91#[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            // RMCP treats a missing expiry as unknown and uses the access token
157            // as-is. Treat a known-expired timestamp as an explicit zero so
158            // startup refreshes the token before the first request.
159            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/// Classifies keyring load failures that affect Auto fallback policy.
231#[derive(Debug, thiserror::Error)]
232enum OAuthKeyringLoadError {
233    /// Store coordination failed, so consulting another authority would be unsafe.
234    #[error(transparent)]
235    StoreLock(#[from] OAuthStoreLockFailure),
236    /// The selected keyring backend itself was unavailable or its data was invalid.
237    #[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    // This exact-store writer is used after a client resolves its authority. Only login-time
272    // policy resolution may clean up or update the non-selected store.
273    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
304/// Saves one credential while holding the Secrets aggregate-store lock across the mutation.
305fn 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
320/// Writes one credential to Secrets. The caller must hold the Secrets aggregate-store lock.
321fn 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
340/// Saves to the selected keyring backend, then best-effort removes the fallback File entry.
341fn 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        // As on load, a store lock failure is a coordination failure rather than evidence that
374        // the keyring backend is unavailable. Falling back could leave a newer File token hidden
375        // behind a stale Secrets entry.
376        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    /// Persists RMCP-managed credential changes back to this client's resolved authority.
514    #[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
641/// Saves one credential while holding the File aggregate-store lock across the full
642/// read-modify-write operation.
643fn 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
648/// Updates the fallback File. The caller must hold the File aggregate-store lock.
649fn 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
747/// Derive a valid secret-store name from the MCP OAuth store key.
748///
749/// `compute_store_key` intentionally includes readable identity components and
750/// a pipe separator, but `SecretName` only allows `A-Z`, `0-9`, and `_`.
751/// Re-hashing keeps the secret key deterministic while satisfying that
752/// restricted alphabet.
753fn 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}