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    // Write to a temporary file in the same directory, then rename into place.
463    // This ensures the token file is never left in a truncated state on crash,
464    // and the 0600 mode is set before any content is written.
465    #[cfg(unix)]
466    {
467        use std::io::Write;
468        use std::os::unix::fs::OpenOptionsExt;
469        let parent = token_path.parent().expect("token_path always has a parent");
470        let tmp_path = parent.join(".token.tmp");
471        let mut f = std::fs::OpenOptions::new()
472            .write(true)
473            .create(true)
474            .truncate(true)
475            .mode(0o600)
476            .open(&tmp_path)
477            .map_err(|e| {
478                MemoryError::TokenStorage(format!("failed to open temp token file: {e}"))
479            })?;
480        f.write_all(token.as_bytes()).map_err(|e| {
481            MemoryError::TokenStorage(format!("failed to write temp token file: {e}"))
482        })?;
483        f.write_all(b"\n").map_err(|e| {
484            MemoryError::TokenStorage(format!("failed to write temp token file: {e}"))
485        })?;
486        f.sync_all().map_err(|e| {
487            MemoryError::TokenStorage(format!("failed to sync temp token file: {e}"))
488        })?;
489        drop(f);
490        std::fs::rename(&tmp_path, &token_path).map_err(|e| {
491            MemoryError::TokenStorage(format!("failed to rename token file into place: {e}"))
492        })?;
493    }
494    #[cfg(not(unix))]
495    {
496        std::fs::write(&token_path, format!("{token}\n"))
497            .map_err(|e| MemoryError::TokenStorage(format!("failed to write token file: {e}")))?;
498    }
499
500    info!("token stored in file ({})", token_path.display());
501    Ok(())
502}
503
504// ---------------------------------------------------------------------------
505// Kubernetes Secret storage (k8s feature)
506// ---------------------------------------------------------------------------
507
508#[cfg(feature = "k8s")]
509async fn store_in_k8s_secret(token: &str, config: &K8sSecretConfig) -> Result<(), MemoryError> {
510    use k8s_openapi::api::core::v1::Secret;
511    use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
512    use kube::{api::PostParams, Api, Client};
513    use std::collections::BTreeMap;
514
515    let client = Client::try_default().await.map_err(|e| {
516        MemoryError::TokenStorage(format!(
517            "Failed to initialize Kubernetes client. Ensure KUBECONFIG is set \
518             or the pod has a service account: {e}"
519        ))
520    })?;
521
522    let secrets: Api<Secret> = Api::namespaced(client, &config.namespace);
523    let secret_name = &config.secret_name;
524
525    let mut data = BTreeMap::new();
526    data.insert(
527        "token".to_string(),
528        k8s_openapi::ByteString(token.as_bytes().to_vec()),
529    );
530
531    let mut labels = BTreeMap::new();
532    labels.insert(
533        "app.kubernetes.io/managed-by".to_string(),
534        "memory-mcp".to_string(),
535    );
536    labels.insert(
537        "app.kubernetes.io/component".to_string(),
538        "auth".to_string(),
539    );
540
541    let mut secret = Secret {
542        metadata: ObjectMeta {
543            name: Some(secret_name.clone()),
544            namespace: Some(config.namespace.clone()),
545            labels: Some(labels),
546            ..Default::default()
547        },
548        data: Some(data),
549        type_: Some("Opaque".to_string()),
550        ..Default::default()
551    };
552
553    // Create-first: attempt to create the secret; on 409 AlreadyExists, GET to
554    // fetch the current resourceVersion and replace. This avoids the TOCTOU
555    // race inherent in a GET-then-create/replace approach.
556    match secrets.create(&PostParams::default(), &secret).await {
557        Ok(_) => {
558            debug!(
559                "created Kubernetes Secret '{secret_name}' in namespace '{}'",
560                config.namespace
561            );
562        }
563        Err(kube::Error::Api(ref err_resp)) if err_resp.code == 409 => {
564            // Secret already exists; fetch resourceVersion then replace.
565            let existing = secrets
566                .get(secret_name)
567                .await
568                .map_err(|e| map_kube_error(e, &config.namespace))?;
569            secret.metadata.resource_version = existing.metadata.resource_version;
570            secrets
571                .replace(secret_name, &PostParams::default(), &secret)
572                .await
573                .map_err(|e| map_kube_error(e, &config.namespace))?;
574            debug!(
575                "updated Kubernetes Secret '{secret_name}' in namespace '{}'",
576                config.namespace
577            );
578        }
579        Err(e) => {
580            return Err(map_kube_error(e, &config.namespace));
581        }
582    }
583
584    eprintln!(
585        "Token stored in Kubernetes Secret '{secret_name}' (namespace: {})",
586        config.namespace
587    );
588    Ok(())
589}
590
591#[cfg(feature = "k8s")]
592fn map_kube_error(e: kube::Error, namespace: &str) -> MemoryError {
593    match &e {
594        kube::Error::Api(err_resp) if err_resp.code == 403 => MemoryError::TokenStorage(format!(
595            "Access denied. Ensure the service account has RBAC permission \
596                 for secrets in namespace '{namespace}': {e}"
597        )),
598        kube::Error::Api(err_resp) if err_resp.code == 404 => {
599            MemoryError::TokenStorage(format!("Namespace '{namespace}' does not exist: {e}"))
600        }
601        _ => MemoryError::TokenStorage(format!("Kubernetes API error: {e}")),
602    }
603}
604
605// ---------------------------------------------------------------------------
606// Auth status
607// ---------------------------------------------------------------------------
608
609/// Print the current authentication status to stdout.
610///
611/// Shows the source of the resolved token and a redacted preview.
612/// Never prints the full token value.
613pub fn print_auth_status(provider: &AuthProvider) {
614    match provider.resolve_with_source() {
615        Ok((token, source)) => {
616            let raw = token.expose_secret();
617            let preview = if raw.len() >= 8 {
618                format!("...{}", &raw[raw.len() - 4..])
619            } else {
620                "****".to_string()
621            };
622            println!("Authenticated via {source}");
623            println!("Token: {preview}");
624        }
625        Err(_) => {
626            println!("No token configured.");
627            println!("Run `memory-mcp auth login` to authenticate with GitHub.");
628        }
629    }
630}
631
632// ---------------------------------------------------------------------------
633// Permission check (Unix only)
634// ---------------------------------------------------------------------------
635
636/// Warn if the token file has permissions that are wider than 0o600.
637fn check_token_file_permissions(path: &std::path::Path) {
638    #[cfg(unix)]
639    {
640        use std::os::unix::fs::MetadataExt;
641        match std::fs::metadata(path) {
642            Ok(meta) => {
643                let mode = meta.mode() & 0o777;
644                if mode != 0o600 {
645                    warn!(
646                        "token file '{}' has permissions {:04o}; \
647                         expected 0600 — consider running: chmod 600 {}",
648                        path.display(),
649                        mode,
650                        path.display()
651                    );
652                }
653            }
654            Err(e) => {
655                warn!("could not read permissions for '{}': {}", path.display(), e);
656            }
657        }
658    }
659    // On non-Unix platforms there are no POSIX permissions to check.
660    #[cfg(not(unix))]
661    let _ = path;
662}
663
664// ---------------------------------------------------------------------------
665// Platform-portable home directory helper
666// ---------------------------------------------------------------------------
667
668/// Returns the user's home directory using the platform-native mechanism.
669///
670/// Delegates to [`homedir::my_home`], which checks `HOME` on Unix and the
671/// Windows profile directory on Windows — no deprecated `std::env::home_dir`.
672pub fn home_dir() -> Option<std::path::PathBuf> {
673    homedir::my_home().ok().flatten()
674}
675
676// ---------------------------------------------------------------------------
677// Tests
678// ---------------------------------------------------------------------------
679
680#[cfg(test)]
681mod tests {
682    use std::sync::Mutex;
683
684    use super::*;
685
686    // Serialise all tests that mutate environment variables so they don't race
687    // under `cargo test` (which runs tests in parallel by default).
688    static ENV_LOCK: Mutex<()> = Mutex::new(());
689
690    #[test]
691    fn test_resolve_from_env_var() {
692        let _guard = ENV_LOCK.lock().unwrap();
693        let token_value = "ghp_test_env_token_abc123";
694        std::env::set_var(ENV_VAR, token_value);
695        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
696        std::env::remove_var(ENV_VAR);
697
698        assert!(result.is_ok(), "expected Ok but got: {result:?}");
699        assert_eq!(result.unwrap().expose_secret(), token_value);
700    }
701
702    #[test]
703    fn test_resolve_trims_env_var_whitespace() {
704        let _guard = ENV_LOCK.lock().unwrap();
705        let token_value = "  ghp_padded_token  ";
706        std::env::set_var(ENV_VAR, token_value);
707        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
708        std::env::remove_var(ENV_VAR);
709
710        assert!(result.is_ok());
711        assert_eq!(result.unwrap().expose_secret(), token_value.trim());
712    }
713
714    #[test]
715    fn test_resolve_prefers_env_over_file() {
716        let _guard = ENV_LOCK.lock().unwrap();
717        // Write a token file and simultaneously set the env var; env must win.
718        let dir = tempfile::tempdir().unwrap();
719        let file_path = dir.path().join("token");
720        std::fs::write(&file_path, "ghp_file_token").unwrap();
721
722        let env_token = "ghp_env_wins";
723        std::env::set_var(ENV_VAR, env_token);
724
725        // Override HOME so the file lookup would pick up our temp file if env
726        // were not consulted first.  We rely on env taking precedence, so
727        // this primarily tests ordering rather than actual file resolution.
728        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
729        std::env::remove_var(ENV_VAR);
730
731        assert!(result.is_ok());
732        assert_eq!(result.unwrap().expose_secret(), env_token);
733    }
734
735    #[test]
736    fn test_try_resolve_with_source_returns_env_var_source() {
737        let _guard = ENV_LOCK.lock().unwrap();
738        let token_value = "ghp_source_test_abc";
739        std::env::set_var(ENV_VAR, token_value);
740        let result = AuthProvider::try_resolve_with_source();
741        std::env::remove_var(ENV_VAR);
742
743        assert!(result.is_ok(), "expected Ok but got: {result:?}");
744        let (tok, source) = result.unwrap();
745        assert_eq!(tok.expose_secret(), token_value);
746        assert!(
747            matches!(source, TokenSource::EnvVar),
748            "expected TokenSource::EnvVar, got: {source:?}"
749        );
750    }
751
752    #[test]
753    fn test_store_token_file_backend() {
754        let dir = tempfile::tempdir().unwrap();
755        let token_dir = dir.path().join(".config").join("memory-mcp");
756        let token_path = token_dir.join("token");
757
758        // Temporarily override HOME.
759        let _guard = ENV_LOCK.lock().unwrap();
760        let original_home = std::env::var("HOME").ok();
761        std::env::set_var("HOME", dir.path());
762
763        let result = store_in_file("ghp_file_backend_test");
764
765        // Restore HOME before asserting so other tests aren't affected.
766        match original_home {
767            Some(h) => std::env::set_var("HOME", h),
768            None => std::env::remove_var("HOME"),
769        }
770
771        assert!(result.is_ok(), "store_in_file failed: {result:?}");
772        assert!(token_path.exists(), "token file was not created");
773
774        let content = std::fs::read_to_string(&token_path).unwrap();
775        assert_eq!(content, "ghp_file_backend_test\n");
776
777        // Verify 0o600 permissions on Unix.
778        #[cfg(unix)]
779        {
780            use std::os::unix::fs::MetadataExt;
781            let mode = std::fs::metadata(&token_path).unwrap().mode() & 0o777;
782            assert_eq!(mode, 0o600, "expected 0600 permissions, got {:04o}", mode);
783        }
784    }
785
786    /// This test exercises the keyring path and requires a live D-Bus /
787    /// secret-service backend.  Mark it `#[ignore]` so it does not run in CI.
788    #[test]
789    #[ignore = "requires live system keyring (D-Bus/GNOME Keyring/KWallet)"]
790    fn test_resolve_from_keyring_ignored_in_ci() {
791        let _guard = ENV_LOCK.lock().unwrap();
792        // Pre-condition: no env var, no token file (rely on absence).
793        std::env::remove_var(ENV_VAR);
794
795        // Attempt to store then retrieve; if the keyring is unavailable the
796        // test is inconclusive rather than failing.
797        let entry = keyring::Entry::new("memory-mcp", "github-token")
798            .expect("keyring entry creation should succeed");
799        let test_token = "ghp_keyring_test_token";
800        entry
801            .set_password(test_token)
802            .expect("storing token should succeed");
803
804        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
805        let _ = entry.delete_credential(); // cleanup before assert
806        assert!(result.is_ok(), "expected token from keyring: {result:?}");
807        assert_eq!(result.unwrap().expose_secret(), test_token);
808    }
809
810    /// Device flow requires real GitHub interaction — skip in CI.
811    #[tokio::test]
812    #[ignore = "requires real GitHub OAuth interaction"]
813    async fn test_device_flow_login_ignored_in_ci() {
814        device_flow_login(
815            Some(StoreBackend::Stdout),
816            #[cfg(feature = "k8s")]
817            None,
818        )
819        .await
820        .expect("device flow should succeed");
821    }
822
823    #[cfg(feature = "k8s")]
824    #[test]
825    #[ignore] // Requires a real Kubernetes cluster
826    fn test_store_in_k8s_secret_ignored_in_ci() {
827        // Placeholder for manual/integration testing
828    }
829}