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