Skip to main content

memory_mcp/
auth.rs

1use std::fmt;
2
3use secrecy::{ExposeSecret, SecretString};
4use tracing::{debug, info, warn};
5
6use crate::error::MemoryError;
7
8/// Token resolution order:
9/// 1. `MEMORY_MCP_GITHUB_TOKEN` environment variable
10/// 2. `~/.config/memory-mcp/token` file
11/// 3. System keyring (GNOME Keyring / KWallet / macOS Keychain)
12const ENV_VAR: &str = "MEMORY_MCP_GITHUB_TOKEN";
13const TOKEN_FILE: &str = ".config/memory-mcp/token";
14
15const GITHUB_CLIENT_ID: &str = "Ov23liWxHYkwXTxCrYHp";
16const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
17const GITHUB_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
18
19// ---------------------------------------------------------------------------
20// StoreBackend — where to persist a newly acquired token
21// ---------------------------------------------------------------------------
22
23/// Token storage backend selection for `memory-mcp auth login`.
24#[derive(Clone, Debug, clap::ValueEnum)]
25#[non_exhaustive]
26pub enum StoreBackend {
27    /// Store token in the system keyring.
28    Keyring,
29    /// Store token in `~/.config/memory-mcp/token`.
30    File,
31    /// Print token to stdout and do not persist it.
32    Stdout,
33    /// Store token as a Kubernetes Secret.
34    #[cfg(feature = "k8s")]
35    #[clap(name = "k8s-secret")]
36    K8sSecret,
37}
38
39// ---------------------------------------------------------------------------
40// K8sSecretConfig — configuration for Kubernetes Secret storage
41// ---------------------------------------------------------------------------
42
43/// Configuration for storing a token as a Kubernetes Secret.
44#[cfg(feature = "k8s")]
45#[derive(Debug)]
46pub struct K8sSecretConfig {
47    /// Kubernetes namespace in which to create or update the secret.
48    pub namespace: String,
49    /// Name of the Kubernetes Secret resource.
50    pub secret_name: String,
51}
52
53// ---------------------------------------------------------------------------
54// TokenSource — tracks which resolution step found the token
55// ---------------------------------------------------------------------------
56
57/// Indicates which source produced the resolved token.
58#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum TokenSource {
61    /// Token was read from the `MEMORY_MCP_GITHUB_TOKEN` environment variable.
62    EnvVar,
63    /// Token was read from the `~/.config/memory-mcp/token` file.
64    File,
65    /// Token was read from the system keyring (GNOME Keyring / KWallet / macOS Keychain).
66    Keyring,
67    /// Token was provided directly via constructor.
68    Explicit,
69}
70
71impl fmt::Display for TokenSource {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            TokenSource::EnvVar => write!(f, "environment variable ({})", ENV_VAR),
75            TokenSource::File => write!(f, "token file (~/.config/memory-mcp/token)"),
76            TokenSource::Keyring => write!(f, "system keyring"),
77            TokenSource::Explicit => write!(f, "explicit token"),
78        }
79    }
80}
81
82// ---------------------------------------------------------------------------
83// Serde structs for OAuth responses
84// ---------------------------------------------------------------------------
85
86#[derive(serde::Deserialize)]
87struct DeviceCodeResponse {
88    device_code: String,
89    user_code: String,
90    verification_uri: String,
91    expires_in: u64,
92    interval: u64,
93}
94
95#[derive(serde::Deserialize)]
96struct AccessTokenResponse {
97    #[serde(default)]
98    access_token: Option<String>,
99    #[serde(default)]
100    error: Option<String>,
101    #[serde(default)]
102    error_description: Option<String>,
103}
104
105// ---------------------------------------------------------------------------
106// AuthProvider
107// ---------------------------------------------------------------------------
108
109/// Resolves and caches a GitHub personal access token from multiple sources.
110pub struct AuthProvider {
111    /// Cached token and its source, if resolved at startup.
112    token: Option<(SecretString, TokenSource)>,
113}
114
115impl AuthProvider {
116    /// Create an `AuthProvider`, eagerly attempting token resolution.
117    ///
118    /// Does not fail if no token is available — some deployments may not
119    /// need remote sync. Call [`Self::resolve_token`] when a token is required.
120    pub fn new() -> Self {
121        let token = Self::try_resolve_with_source().ok();
122        if token.is_some() {
123            debug!("AuthProvider: token resolved at startup");
124        } else {
125            debug!("AuthProvider: no token available at startup");
126        }
127        Self { token }
128    }
129
130    /// Resolve a GitHub personal access token, returning it wrapped in
131    /// [`SecretString`] so it cannot accidentally appear in logs or error chains.
132    ///
133    /// Checks (in order):
134    /// 1. `MEMORY_MCP_GITHUB_TOKEN` env var
135    /// 2. `~/.config/memory-mcp/token` file
136    /// 3. System keyring (GNOME Keyring / KWallet / macOS Keychain)
137    pub fn resolve_token(&self) -> Result<SecretString, MemoryError> {
138        self.resolve_with_source().map(|(tok, _)| tok)
139    }
140
141    /// Resolve the token and return which source provided it.
142    ///
143    /// Like [`resolve_token`](Self::resolve_token), returns the cached token if
144    /// one was resolved at startup. Falls back to the resolution chain
145    /// (env var → file → keyring) if no cached token exists.
146    pub fn resolve_with_source(&self) -> Result<(SecretString, TokenSource), MemoryError> {
147        if let Some((ref t, ref s)) = self.token {
148            return Ok((t.clone(), s.clone()));
149        }
150        Self::try_resolve_with_source()
151    }
152
153    // -----------------------------------------------------------------------
154    // Private helpers
155    // -----------------------------------------------------------------------
156
157    /// Resolve the token and return both the raw value and which source provided it.
158    fn try_resolve_with_source() -> Result<(SecretString, TokenSource), MemoryError> {
159        // 1. Environment variable.
160        if let Ok(tok) = std::env::var(ENV_VAR) {
161            if !tok.trim().is_empty() {
162                return Ok((
163                    SecretString::from(tok.trim().to_string()),
164                    TokenSource::EnvVar,
165                ));
166            }
167        }
168
169        // 2. Token file.
170        if let Some(home) = home_dir() {
171            let path = home.join(TOKEN_FILE);
172            if path.exists() {
173                // Check permissions: warn if the file is world- or group-readable.
174                check_token_file_permissions(&path);
175
176                let raw = std::fs::read_to_string(&path)?;
177                let tok = raw.trim().to_string();
178                if !tok.is_empty() {
179                    return Ok((SecretString::from(tok), TokenSource::File));
180                }
181            }
182        }
183
184        // 3. System keyring (GNOME Keyring / KWallet / macOS Keychain).
185        match keyring::Entry::new("memory-mcp", "github-token") {
186            Ok(entry) => match entry.get_password() {
187                Ok(tok) if !tok.trim().is_empty() => {
188                    info!("resolved GitHub token from system keyring");
189                    return Ok((
190                        SecretString::from(tok.trim().to_string()),
191                        TokenSource::Keyring,
192                    ));
193                }
194                Ok(_) => { /* empty password stored — fall through */ }
195                Err(keyring::Error::NoEntry) => { /* no entry — fall through */ }
196                Err(keyring::Error::NoStorageAccess(_)) => {
197                    debug!("keyring: no storage backend available (headless?)");
198                }
199                Err(e) => {
200                    warn!("keyring: unexpected error: {e}");
201                }
202            },
203            Err(e) => {
204                debug!("keyring: could not create entry: {e}");
205            }
206        }
207
208        Err(MemoryError::Auth(
209            "no token available; set MEMORY_MCP_GITHUB_TOKEN, add \
210             ~/.config/memory-mcp/token, or store a token in the system keyring \
211             under service 'memory-mcp', account 'github-token'."
212                .to_string(),
213        ))
214    }
215}
216
217impl AuthProvider {
218    /// Create an `AuthProvider` with a pre-set token.
219    ///
220    /// Use this when you already have a token from your own auth flow
221    /// and want to skip the built-in resolution chain (env var → file → keyring).
222    pub fn with_token(token: &str) -> Self {
223        Self {
224            token: Some((SecretString::from(token.to_string()), TokenSource::Explicit)),
225        }
226    }
227}
228
229impl Default for AuthProvider {
230    fn default() -> Self {
231        Self::new()
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Device flow login
237// ---------------------------------------------------------------------------
238
239/// Authenticate with GitHub via the OAuth device flow and persist the token.
240///
241/// Prints user-facing prompts to stderr. Never logs the token value.
242pub async fn device_flow_login(
243    store: Option<StoreBackend>,
244    #[cfg(feature = "k8s")] k8s_config: Option<K8sSecretConfig>,
245) -> Result<(), MemoryError> {
246    use std::time::{Duration, Instant};
247    use tokio::time::sleep;
248
249    let client = reqwest::Client::builder()
250        .connect_timeout(Duration::from_secs(10))
251        .timeout(Duration::from_secs(30))
252        .build()
253        .map_err(|e| MemoryError::OAuth(format!("failed to build HTTP client: {e}")))?;
254
255    // Step 1: Request a device code.
256    let device_resp = client
257        .post(GITHUB_DEVICE_CODE_URL)
258        .header("Accept", "application/json")
259        .form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
260        .send()
261        .await
262        .map_err(|e| {
263            MemoryError::OAuth(format!(
264                "failed to contact GitHub device code endpoint: {e}"
265            ))
266        })?
267        .error_for_status()
268        .map_err(|e| MemoryError::OAuth(format!("GitHub device code request failed: {e}")))?
269        .json::<DeviceCodeResponse>()
270        .await
271        .map_err(|e| MemoryError::OAuth(format!("failed to parse device code response: {e}")))?;
272
273    // Compute overall deadline from expires_in, capped at 30 minutes to guard
274    // against a compromised response setting an excessively long expiry.
275    let expires_in = device_resp.expires_in.min(1800);
276    let deadline = Instant::now() + Duration::from_secs(expires_in);
277
278    // Step 2: Display instructions to the user.
279    eprintln!();
280    eprintln!("  Open this URL in your browser:");
281    eprintln!("    {}", device_resp.verification_uri);
282    eprintln!();
283    eprintln!("  Enter this code when prompted:");
284    eprintln!("    {}", device_resp.user_code);
285    eprintln!();
286    eprintln!("  Waiting for authorization...");
287
288    // Step 3: Poll for the access token.
289    let mut poll_interval = device_resp.interval.clamp(1, 30);
290    let token = loop {
291        if Instant::now() >= deadline {
292            return Err(MemoryError::OAuth(format!(
293                "Device code expired after {expires_in} seconds"
294            )));
295        }
296
297        sleep(Duration::from_secs(poll_interval)).await;
298
299        let resp = client
300            .post(GITHUB_ACCESS_TOKEN_URL)
301            .header("Accept", "application/json")
302            .form(&[
303                ("client_id", GITHUB_CLIENT_ID),
304                ("device_code", &device_resp.device_code),
305                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
306            ])
307            .send()
308            .await
309            .map_err(|e| MemoryError::OAuth(format!("polling GitHub token endpoint failed: {e}")))?
310            .error_for_status()
311            .map_err(|e| {
312                MemoryError::OAuth(format!("GitHub token request returned error status: {e}"))
313            })?
314            .json::<AccessTokenResponse>()
315            .await
316            .map_err(|e| MemoryError::OAuth(format!("failed to parse token response: {e}")))?;
317
318        if let Some(tok) = resp.access_token.filter(|t| !t.trim().is_empty()) {
319            break SecretString::from(tok);
320        }
321
322        match resp.error.as_deref() {
323            Some("authorization_pending") => {
324                // Normal — user has not yet approved; keep polling.
325                continue;
326            }
327            Some("slow_down") => {
328                // GitHub asked us to back off; add 5 s to interval, capped at 60 s.
329                poll_interval = (poll_interval + 5).min(60);
330                continue;
331            }
332            Some("expired_token") => {
333                return Err(MemoryError::OAuth(
334                    "device code expired; please run `memory-mcp auth login` again".to_string(),
335                ));
336            }
337            Some("access_denied") => {
338                return Err(MemoryError::OAuth(
339                    "authorization denied by user".to_string(),
340                ));
341            }
342            Some(other) => {
343                let desc = resp
344                    .error_description
345                    .as_deref()
346                    .unwrap_or("no description");
347                return Err(MemoryError::OAuth(format!(
348                    "unexpected OAuth error '{other}': {desc}"
349                )));
350            }
351            None => {
352                return Err(MemoryError::OAuth(
353                    "GitHub returned neither an access_token nor an error field; \
354                     unexpected response"
355                        .to_string(),
356                ));
357            }
358        }
359    };
360
361    // Step 4: Store the token.
362    store_token(
363        &token,
364        store,
365        #[cfg(feature = "k8s")]
366        k8s_config,
367    )
368    .await?;
369    eprintln!("Authentication successful.");
370
371    Ok(())
372}
373
374// ---------------------------------------------------------------------------
375// Token storage
376// ---------------------------------------------------------------------------
377
378/// Persist a token via the specified backend.
379///
380/// Never logs the token value — only the chosen storage destination.
381async fn store_token(
382    token: &SecretString,
383    backend: Option<StoreBackend>,
384    #[cfg(feature = "k8s")] k8s_config: Option<K8sSecretConfig>,
385) -> Result<(), MemoryError> {
386    match backend {
387        Some(StoreBackend::Stdout) => {
388            println!("{}", token.expose_secret());
389            debug!("token written to stdout");
390        }
391        Some(StoreBackend::Keyring) => {
392            store_in_keyring(token.expose_secret())?;
393        }
394        Some(StoreBackend::File) => {
395            store_in_file(token.expose_secret())?;
396        }
397        #[cfg(feature = "k8s")]
398        Some(StoreBackend::K8sSecret) => {
399            let config = k8s_config.ok_or_else(|| {
400                MemoryError::TokenStorage(
401                    "k8s-secret backend requires namespace and secret name".into(),
402                )
403            })?;
404            store_in_k8s_secret(token.expose_secret(), &config).await?;
405        }
406        None => {
407            // No --store flag: try keyring ONLY. Do NOT fall back to file.
408            store_in_keyring(token.expose_secret()).map_err(|e| {
409                MemoryError::TokenStorage(format!(
410                    "Keyring unavailable: {e}. Use --store file to write to \
411                     ~/.config/memory-mcp/token, --store stdout to print the token\
412                     {k8s_hint}.",
413                    k8s_hint = if cfg!(feature = "k8s") {
414                        ", or --store k8s-secret to store in a Kubernetes Secret"
415                    } else {
416                        ""
417                    }
418                ))
419            })?;
420        }
421    }
422    Ok(())
423}
424
425fn store_in_keyring(token: &str) -> Result<(), MemoryError> {
426    let entry = keyring::Entry::new("memory-mcp", "github-token")
427        .map_err(|e| MemoryError::TokenStorage(format!("failed to create keyring entry: {e}")))?;
428    entry
429        .set_password(token)
430        .map_err(|e| MemoryError::TokenStorage(format!("failed to store token in keyring: {e}")))?;
431    info!("token stored in system keyring");
432    Ok(())
433}
434
435fn store_in_file(token: &str) -> Result<(), MemoryError> {
436    let home =
437        home_dir().ok_or_else(|| MemoryError::TokenStorage("HOME directory is not set".into()))?;
438    let token_path = home.join(TOKEN_FILE);
439
440    if let Some(parent) = token_path.parent() {
441        std::fs::create_dir_all(parent).map_err(|e| {
442            MemoryError::TokenStorage(format!(
443                "failed to create config directory {}: {e}",
444                parent.display()
445            ))
446        })?;
447
448        // Set config directory to 0700 on Unix.
449        #[cfg(unix)]
450        {
451            use std::os::unix::fs::PermissionsExt;
452            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(
453                |e| {
454                    MemoryError::TokenStorage(format!(
455                        "failed to set config directory permissions: {e}"
456                    ))
457                },
458            )?;
459        }
460    }
461
462    // Atomic write: temp file → sync → rename. On Unix the temp file is
463    // created with mode 0o600. On any failure the temp file is cleaned up
464    // automatically by the RAII guard inside `atomic_write`.
465    crate::fs_util::atomic_write(&token_path, format!("{token}\n").as_bytes())
466        .map_err(|e| MemoryError::TokenStorage(format!("failed to write token file: {e}")))?;
467
468    info!("token stored in file ({})", token_path.display());
469    Ok(())
470}
471
472// ---------------------------------------------------------------------------
473// Kubernetes Secret storage (k8s feature)
474// ---------------------------------------------------------------------------
475
476#[cfg(feature = "k8s")]
477async fn store_in_k8s_secret(token: &str, config: &K8sSecretConfig) -> Result<(), MemoryError> {
478    use k8s_openapi::api::core::v1::Secret;
479    use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
480    use kube::{api::PostParams, Api, Client};
481    use std::collections::BTreeMap;
482
483    let client = Client::try_default().await.map_err(|e| {
484        MemoryError::TokenStorage(format!(
485            "Failed to initialize Kubernetes client. Ensure KUBECONFIG is set \
486             or the pod has a service account: {e}"
487        ))
488    })?;
489
490    let secrets: Api<Secret> = Api::namespaced(client, &config.namespace);
491    let secret_name = &config.secret_name;
492
493    let mut data = BTreeMap::new();
494    data.insert(
495        "token".to_string(),
496        k8s_openapi::ByteString(token.as_bytes().to_vec()),
497    );
498
499    let mut labels = BTreeMap::new();
500    labels.insert(
501        "app.kubernetes.io/managed-by".to_string(),
502        "memory-mcp".to_string(),
503    );
504    labels.insert(
505        "app.kubernetes.io/component".to_string(),
506        "auth".to_string(),
507    );
508
509    let mut secret = Secret {
510        metadata: ObjectMeta {
511            name: Some(secret_name.clone()),
512            namespace: Some(config.namespace.clone()),
513            labels: Some(labels),
514            ..Default::default()
515        },
516        data: Some(data),
517        type_: Some("Opaque".to_string()),
518        ..Default::default()
519    };
520
521    // Create-first: attempt to create the secret; on 409 AlreadyExists, GET to
522    // fetch the current resourceVersion and replace. This avoids the TOCTOU
523    // race inherent in a GET-then-create/replace approach.
524    match secrets.create(&PostParams::default(), &secret).await {
525        Ok(_) => {
526            debug!(
527                "created Kubernetes Secret '{secret_name}' in namespace '{}'",
528                config.namespace
529            );
530        }
531        Err(kube::Error::Api(ref err_resp)) if err_resp.code == 409 => {
532            // Secret already exists; fetch resourceVersion then replace.
533            let existing = secrets
534                .get(secret_name)
535                .await
536                .map_err(|e| map_kube_error(e, &config.namespace))?;
537            secret.metadata.resource_version = existing.metadata.resource_version;
538            secrets
539                .replace(secret_name, &PostParams::default(), &secret)
540                .await
541                .map_err(|e| map_kube_error(e, &config.namespace))?;
542            debug!(
543                "updated Kubernetes Secret '{secret_name}' in namespace '{}'",
544                config.namespace
545            );
546        }
547        Err(e) => {
548            return Err(map_kube_error(e, &config.namespace));
549        }
550    }
551
552    eprintln!(
553        "Token stored in Kubernetes Secret '{secret_name}' (namespace: {})",
554        config.namespace
555    );
556    Ok(())
557}
558
559#[cfg(feature = "k8s")]
560fn map_kube_error(e: kube::Error, namespace: &str) -> MemoryError {
561    match &e {
562        kube::Error::Api(err_resp) if err_resp.code == 403 => MemoryError::TokenStorage(format!(
563            "Access denied. Ensure the service account has RBAC permission \
564                 for secrets in namespace '{namespace}': {e}"
565        )),
566        kube::Error::Api(err_resp) if err_resp.code == 404 => {
567            MemoryError::TokenStorage(format!("Namespace '{namespace}' does not exist: {e}"))
568        }
569        _ => MemoryError::TokenStorage(format!("Kubernetes API error: {e}")),
570    }
571}
572
573// ---------------------------------------------------------------------------
574// Auth status
575// ---------------------------------------------------------------------------
576
577/// Print the current authentication status to stdout.
578///
579/// Shows the source of the resolved token and a redacted preview.
580/// Never prints the full token value.
581pub fn print_auth_status(provider: &AuthProvider) {
582    match provider.resolve_with_source() {
583        Ok((token, source)) => {
584            let raw = token.expose_secret();
585            let preview = if raw.len() >= 8 {
586                format!("...{}", &raw[raw.len() - 4..])
587            } else {
588                "****".to_string()
589            };
590            println!("Authenticated via {source}");
591            println!("Token: {preview}");
592        }
593        Err(_) => {
594            println!("No token configured.");
595            println!("Run `memory-mcp auth login` to authenticate with GitHub.");
596        }
597    }
598}
599
600// ---------------------------------------------------------------------------
601// Permission check (Unix only)
602// ---------------------------------------------------------------------------
603
604/// Warn if the token file has permissions that are wider than 0o600.
605fn check_token_file_permissions(path: &std::path::Path) {
606    #[cfg(unix)]
607    {
608        use std::os::unix::fs::MetadataExt;
609        match std::fs::metadata(path) {
610            Ok(meta) => {
611                let mode = meta.mode() & 0o777;
612                if mode != 0o600 {
613                    warn!(
614                        "token file '{}' has permissions {:04o}; \
615                         expected 0600 — consider running: chmod 600 {}",
616                        path.display(),
617                        mode,
618                        path.display()
619                    );
620                }
621            }
622            Err(e) => {
623                warn!("could not read permissions for '{}': {}", path.display(), e);
624            }
625        }
626    }
627    // On non-Unix platforms there are no POSIX permissions to check.
628    #[cfg(not(unix))]
629    let _ = path;
630}
631
632// ---------------------------------------------------------------------------
633// Platform-portable home directory helper
634// ---------------------------------------------------------------------------
635
636/// Returns the user's home directory using the platform-native mechanism.
637///
638/// Delegates to [`dirs::home_dir`], which checks `HOME` on Unix and the
639/// Windows profile directory on Windows — no deprecated `std::env::home_dir`.
640pub fn home_dir() -> Option<std::path::PathBuf> {
641    dirs::home_dir()
642}
643
644// ---------------------------------------------------------------------------
645// Tests
646// ---------------------------------------------------------------------------
647
648#[cfg(test)]
649mod tests {
650    use std::sync::Mutex;
651
652    use super::*;
653
654    // Serialise all tests that mutate environment variables so they don't race
655    // under `cargo test` (which runs tests in parallel by default).
656    static ENV_LOCK: Mutex<()> = Mutex::new(());
657
658    #[test]
659    fn test_resolve_from_env_var() {
660        let _guard = ENV_LOCK.lock().unwrap();
661        let token_value = "ghp_test_env_token_abc123";
662        std::env::set_var(ENV_VAR, token_value);
663        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
664        std::env::remove_var(ENV_VAR);
665
666        assert!(result.is_ok(), "expected Ok but got: {result:?}");
667        assert_eq!(result.unwrap().expose_secret(), token_value);
668    }
669
670    #[test]
671    fn test_resolve_trims_env_var_whitespace() {
672        let _guard = ENV_LOCK.lock().unwrap();
673        let token_value = "  ghp_padded_token  ";
674        std::env::set_var(ENV_VAR, token_value);
675        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
676        std::env::remove_var(ENV_VAR);
677
678        assert!(result.is_ok());
679        assert_eq!(result.unwrap().expose_secret(), token_value.trim());
680    }
681
682    #[test]
683    fn test_resolve_prefers_env_over_file() {
684        let _guard = ENV_LOCK.lock().unwrap();
685        // Write a token file and simultaneously set the env var; env must win.
686        let dir = tempfile::tempdir().unwrap();
687        let file_path = dir.path().join("token");
688        std::fs::write(&file_path, "ghp_file_token").unwrap();
689
690        let env_token = "ghp_env_wins";
691        std::env::set_var(ENV_VAR, env_token);
692
693        // Override HOME so the file lookup would pick up our temp file if env
694        // were not consulted first.  We rely on env taking precedence, so
695        // this primarily tests ordering rather than actual file resolution.
696        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
697        std::env::remove_var(ENV_VAR);
698
699        assert!(result.is_ok());
700        assert_eq!(result.unwrap().expose_secret(), env_token);
701    }
702
703    #[test]
704    fn test_try_resolve_with_source_returns_env_var_source() {
705        let _guard = ENV_LOCK.lock().unwrap();
706        let token_value = "ghp_source_test_abc";
707        std::env::set_var(ENV_VAR, token_value);
708        let result = AuthProvider::try_resolve_with_source();
709        std::env::remove_var(ENV_VAR);
710
711        assert!(result.is_ok(), "expected Ok but got: {result:?}");
712        let (tok, source) = result.unwrap();
713        assert_eq!(tok.expose_secret(), token_value);
714        assert!(
715            matches!(source, TokenSource::EnvVar),
716            "expected TokenSource::EnvVar, got: {source:?}"
717        );
718    }
719
720    #[test]
721    fn test_store_token_file_backend() {
722        let dir = tempfile::tempdir().unwrap();
723        let token_dir = dir.path().join(".config").join("memory-mcp");
724        let token_path = token_dir.join("token");
725
726        // Temporarily override HOME.
727        let _guard = ENV_LOCK.lock().unwrap();
728        let original_home = std::env::var("HOME").ok();
729        std::env::set_var("HOME", dir.path());
730
731        let result = store_in_file("ghp_file_backend_test");
732
733        // Restore HOME before asserting so other tests aren't affected.
734        match original_home {
735            Some(h) => std::env::set_var("HOME", h),
736            None => std::env::remove_var("HOME"),
737        }
738
739        assert!(result.is_ok(), "store_in_file failed: {result:?}");
740        assert!(token_path.exists(), "token file was not created");
741
742        let content = std::fs::read_to_string(&token_path).unwrap();
743        assert_eq!(content, "ghp_file_backend_test\n");
744
745        // Verify 0o600 permissions on Unix.
746        #[cfg(unix)]
747        {
748            use std::os::unix::fs::MetadataExt;
749            let mode = std::fs::metadata(&token_path).unwrap().mode() & 0o777;
750            assert_eq!(mode, 0o600, "expected 0600 permissions, got {:04o}", mode);
751        }
752    }
753
754    /// This test exercises the keyring path and requires a live D-Bus /
755    /// secret-service backend.  Mark it `#[ignore]` so it does not run in CI.
756    #[test]
757    #[ignore = "requires live system keyring (D-Bus/GNOME Keyring/KWallet)"]
758    fn test_resolve_from_keyring_ignored_in_ci() {
759        let _guard = ENV_LOCK.lock().unwrap();
760        // Pre-condition: no env var, no token file (rely on absence).
761        std::env::remove_var(ENV_VAR);
762
763        // Attempt to store then retrieve; if the keyring is unavailable the
764        // test is inconclusive rather than failing.
765        let entry = keyring::Entry::new("memory-mcp", "github-token")
766            .expect("keyring entry creation should succeed");
767        let test_token = "ghp_keyring_test_token";
768        entry
769            .set_password(test_token)
770            .expect("storing token should succeed");
771
772        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
773        let _ = entry.delete_credential(); // cleanup before assert
774        assert!(result.is_ok(), "expected token from keyring: {result:?}");
775        assert_eq!(result.unwrap().expose_secret(), test_token);
776    }
777
778    /// Device flow requires real GitHub interaction — skip in CI.
779    #[tokio::test]
780    #[ignore = "requires real GitHub OAuth interaction"]
781    async fn test_device_flow_login_ignored_in_ci() {
782        device_flow_login(
783            Some(StoreBackend::Stdout),
784            #[cfg(feature = "k8s")]
785            None,
786        )
787        .await
788        .expect("device flow should succeed");
789    }
790
791    #[cfg(feature = "k8s")]
792    #[test]
793    #[ignore] // Requires a real Kubernetes cluster
794    fn test_store_in_k8s_secret_ignored_in_ci() {
795        // Placeholder for manual/integration testing
796    }
797}