Skip to main content

memory_mcp/auth/
mod.rs

1use std::fmt;
2
3use secrecy::{ExposeSecret, SecretString};
4use tracing::{debug, info, warn};
5
6use crate::error::MemoryError;
7
8/// OAuth device flow trait and provider implementations.
9pub mod oauth;
10pub use oauth::{device_flow_login, DeviceFlowProvider, GitHubDeviceFlow};
11
12/// Token resolution order:
13/// 1. `MEMORY_MCP_GITHUB_TOKEN` environment variable
14/// 2. `~/.config/memory-mcp/token` file
15/// 3. System keyring (GNOME Keyring / KWallet / macOS Keychain)
16const ENV_VAR: &str = "MEMORY_MCP_GITHUB_TOKEN";
17const TOKEN_FILE: &str = ".config/memory-mcp/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// AuthProvider
84// ---------------------------------------------------------------------------
85
86/// Resolves and caches a GitHub personal access token from multiple sources.
87pub struct AuthProvider {
88    /// Cached token and its source, if resolved at startup.
89    token: Option<(SecretString, TokenSource)>,
90}
91
92impl AuthProvider {
93    /// Create an `AuthProvider`, eagerly attempting token resolution.
94    ///
95    /// Does not fail if no token is available — some deployments may not
96    /// need remote sync. Call [`Self::resolve_token`] when a token is required.
97    pub fn new() -> Self {
98        let token = Self::try_resolve_with_source().ok();
99        if token.is_some() {
100            debug!("AuthProvider: token resolved at startup");
101        } else {
102            debug!("AuthProvider: no token available at startup");
103        }
104        Self { token }
105    }
106
107    /// Resolve a GitHub personal access token, returning it wrapped in
108    /// [`SecretString`] so it cannot accidentally appear in logs or error chains.
109    ///
110    /// Checks (in order):
111    /// 1. `MEMORY_MCP_GITHUB_TOKEN` env var
112    /// 2. `~/.config/memory-mcp/token` file
113    /// 3. System keyring (GNOME Keyring / KWallet / macOS Keychain)
114    pub fn resolve_token(&self) -> Result<SecretString, MemoryError> {
115        self.resolve_with_source().map(|(tok, _)| tok)
116    }
117
118    /// Resolve the token and return which source provided it.
119    ///
120    /// Like [`resolve_token`](Self::resolve_token), returns the cached token if
121    /// one was resolved at startup. Falls back to the resolution chain
122    /// (env var → file → keyring) if no cached token exists.
123    pub fn resolve_with_source(&self) -> Result<(SecretString, TokenSource), MemoryError> {
124        if let Some((ref t, ref s)) = self.token {
125            return Ok((t.clone(), s.clone()));
126        }
127        Self::try_resolve_with_source()
128    }
129
130    // -----------------------------------------------------------------------
131    // Private helpers
132    // -----------------------------------------------------------------------
133
134    /// Resolve the token and return both the raw value and which source provided it.
135    fn try_resolve_with_source() -> Result<(SecretString, TokenSource), MemoryError> {
136        let span = tracing::debug_span!("auth.resolve", token_source = tracing::field::Empty,);
137        let _enter = span.entered();
138
139        // 1. Environment variable.
140        debug!("auth: trying environment variable");
141        if let Ok(tok) = std::env::var(ENV_VAR) {
142            if !tok.trim().is_empty() {
143                tracing::Span::current().record("token_source", "env_var");
144                info!(token_source = "env_var", "auth token resolved");
145                return Ok((
146                    SecretString::from(tok.trim().to_string()),
147                    TokenSource::EnvVar,
148                ));
149            }
150        }
151
152        // 2. Token file.
153        debug!("auth: trying token file");
154        if let Some(home) = home_dir() {
155            let path = home.join(TOKEN_FILE);
156            if path.exists() {
157                // Check permissions: warn if the file is world- or group-readable.
158                check_token_file_permissions(&path);
159
160                let raw = std::fs::read_to_string(&path)?;
161                let tok = raw.trim().to_string();
162                if !tok.is_empty() {
163                    tracing::Span::current().record("token_source", "file");
164                    info!(token_source = "file", "auth token resolved");
165                    return Ok((SecretString::from(tok), TokenSource::File));
166                }
167            }
168        }
169
170        // 3. System keyring (GNOME Keyring / KWallet / macOS Keychain).
171        debug!("auth: trying system keyring");
172        match keyring::Entry::new("memory-mcp", "github-token") {
173            Ok(entry) => match entry.get_password() {
174                Ok(tok) if !tok.trim().is_empty() => {
175                    tracing::Span::current().record("token_source", "keyring");
176                    info!(
177                        token_source = "keyring",
178                        "resolved GitHub token from system keyring"
179                    );
180                    return Ok((
181                        SecretString::from(tok.trim().to_string()),
182                        TokenSource::Keyring,
183                    ));
184                }
185                Ok(_) => { /* empty password stored — fall through */ }
186                Err(keyring::Error::NoEntry) => { /* no entry — fall through */ }
187                Err(keyring::Error::NoStorageAccess(_)) => {
188                    debug!("keyring: no storage backend available (headless?)");
189                }
190                Err(e) => {
191                    warn!("keyring: unexpected error: {e}");
192                }
193            },
194            Err(e) => {
195                debug!("keyring: could not create entry: {e}");
196            }
197        }
198
199        warn!("auth token resolution failed — no token found in env var, file, or keyring");
200        Err(MemoryError::Auth(
201            "no token available; set MEMORY_MCP_GITHUB_TOKEN, add \
202             ~/.config/memory-mcp/token, or store a token in the system keyring \
203             under service 'memory-mcp', account 'github-token'."
204                .to_string(),
205        ))
206    }
207}
208
209impl AuthProvider {
210    /// Create an `AuthProvider` with a pre-set token.
211    ///
212    /// Use this when you already have a token from your own auth flow
213    /// and want to skip the built-in resolution chain (env var → file → keyring).
214    pub fn with_token(token: &str) -> Self {
215        Self {
216            token: Some((SecretString::from(token.to_string()), TokenSource::Explicit)),
217        }
218    }
219}
220
221impl Default for AuthProvider {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227// ---------------------------------------------------------------------------
228// Token storage
229// ---------------------------------------------------------------------------
230
231/// Persist a token via the specified backend.
232///
233/// Never logs the token value — only the chosen storage destination.
234pub(crate) async fn store_token(
235    token: &SecretString,
236    backend: Option<StoreBackend>,
237    #[cfg(feature = "k8s")] k8s_config: Option<K8sSecretConfig>,
238) -> Result<(), MemoryError> {
239    match backend {
240        Some(StoreBackend::Stdout) => {
241            println!("{}", token.expose_secret());
242            debug!("token written to stdout");
243        }
244        Some(StoreBackend::Keyring) => {
245            store_in_keyring(token.expose_secret())?;
246        }
247        Some(StoreBackend::File) => {
248            store_in_file(token.expose_secret())?;
249        }
250        #[cfg(feature = "k8s")]
251        Some(StoreBackend::K8sSecret) => {
252            let config = k8s_config.ok_or_else(|| {
253                MemoryError::TokenStorage(
254                    "k8s-secret backend requires namespace and secret name".into(),
255                )
256            })?;
257            store_in_k8s_secret(token.expose_secret(), &config).await?;
258        }
259        None => {
260            // No --store flag: try keyring ONLY. Do NOT fall back to file.
261            store_in_keyring(token.expose_secret()).map_err(|e| {
262                MemoryError::TokenStorage(format!(
263                    "Keyring unavailable: {e}. Use --store file to write to \
264                     ~/.config/memory-mcp/token, --store stdout to print the token\
265                     {k8s_hint}.",
266                    k8s_hint = if cfg!(feature = "k8s") {
267                        ", or --store k8s-secret to store in a Kubernetes Secret"
268                    } else {
269                        ""
270                    }
271                ))
272            })?;
273        }
274    }
275    Ok(())
276}
277
278fn store_in_keyring(token: &str) -> Result<(), MemoryError> {
279    let entry = keyring::Entry::new("memory-mcp", "github-token")
280        .map_err(|e| MemoryError::TokenStorage(format!("failed to create keyring entry: {e}")))?;
281    entry
282        .set_password(token)
283        .map_err(|e| MemoryError::TokenStorage(format!("failed to store token in keyring: {e}")))?;
284    info!("token stored in system keyring");
285    Ok(())
286}
287
288fn store_in_file(token: &str) -> Result<(), MemoryError> {
289    let home =
290        home_dir().ok_or_else(|| MemoryError::TokenStorage("HOME directory is not set".into()))?;
291    let token_path = home.join(TOKEN_FILE);
292
293    if let Some(parent) = token_path.parent() {
294        std::fs::create_dir_all(parent).map_err(|e| {
295            MemoryError::TokenStorage(format!(
296                "failed to create config directory {}: {e}",
297                parent.display()
298            ))
299        })?;
300
301        // Set config directory to 0700 on Unix.
302        #[cfg(unix)]
303        {
304            use std::os::unix::fs::PermissionsExt;
305            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(
306                |e| {
307                    MemoryError::TokenStorage(format!(
308                        "failed to set config directory permissions: {e}"
309                    ))
310                },
311            )?;
312        }
313    }
314
315    // Atomic write: temp file → sync → rename. On Unix the temp file is
316    // created with mode 0o600. On any failure the temp file is cleaned up
317    // automatically by the RAII guard inside `atomic_write`.
318    crate::fs_util::atomic_write(&token_path, format!("{token}\n").as_bytes())
319        .map_err(|e| MemoryError::TokenStorage(format!("failed to write token file: {e}")))?;
320
321    info!("token stored in file ({})", token_path.display());
322    Ok(())
323}
324
325// ---------------------------------------------------------------------------
326// Kubernetes Secret storage (k8s feature)
327// ---------------------------------------------------------------------------
328
329#[cfg(feature = "k8s")]
330async fn store_in_k8s_secret(token: &str, config: &K8sSecretConfig) -> Result<(), MemoryError> {
331    use k8s_openapi::api::core::v1::Secret;
332    use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
333    use kube::{api::PostParams, Api, Client};
334    use std::collections::BTreeMap;
335
336    let client = Client::try_default().await.map_err(|e| {
337        MemoryError::TokenStorage(format!(
338            "Failed to initialize Kubernetes client. Ensure KUBECONFIG is set \
339             or the pod has a service account: {e}"
340        ))
341    })?;
342
343    let secrets: Api<Secret> = Api::namespaced(client, &config.namespace);
344    let secret_name = &config.secret_name;
345
346    let mut data = BTreeMap::new();
347    data.insert(
348        "token".to_string(),
349        k8s_openapi::ByteString(token.as_bytes().to_vec()),
350    );
351
352    let mut labels = BTreeMap::new();
353    labels.insert(
354        "app.kubernetes.io/managed-by".to_string(),
355        "memory-mcp".to_string(),
356    );
357    labels.insert(
358        "app.kubernetes.io/component".to_string(),
359        "auth".to_string(),
360    );
361
362    let mut secret = Secret {
363        metadata: ObjectMeta {
364            name: Some(secret_name.clone()),
365            namespace: Some(config.namespace.clone()),
366            labels: Some(labels),
367            ..Default::default()
368        },
369        data: Some(data),
370        type_: Some("Opaque".to_string()),
371        ..Default::default()
372    };
373
374    // Create-first: attempt to create the secret; on 409 AlreadyExists, GET to
375    // fetch the current resourceVersion and replace. This avoids the TOCTOU
376    // race inherent in a GET-then-create/replace approach.
377    match secrets.create(&PostParams::default(), &secret).await {
378        Ok(_) => {
379            debug!(
380                "created Kubernetes Secret '{secret_name}' in namespace '{}'",
381                config.namespace
382            );
383        }
384        Err(kube::Error::Api(ref err_resp)) if err_resp.code == 409 => {
385            // Secret already exists; fetch resourceVersion then replace.
386            let existing = secrets
387                .get(secret_name)
388                .await
389                .map_err(|e| map_kube_error(e, &config.namespace))?;
390            secret.metadata.resource_version = existing.metadata.resource_version;
391            secrets
392                .replace(secret_name, &PostParams::default(), &secret)
393                .await
394                .map_err(|e| map_kube_error(e, &config.namespace))?;
395            debug!(
396                "updated Kubernetes Secret '{secret_name}' in namespace '{}'",
397                config.namespace
398            );
399        }
400        Err(e) => {
401            return Err(map_kube_error(e, &config.namespace));
402        }
403    }
404
405    eprintln!(
406        "Token stored in Kubernetes Secret '{secret_name}' (namespace: {})",
407        config.namespace
408    );
409    Ok(())
410}
411
412#[cfg(feature = "k8s")]
413fn map_kube_error(e: kube::Error, namespace: &str) -> MemoryError {
414    match &e {
415        kube::Error::Api(err_resp) if err_resp.code == 403 => MemoryError::TokenStorage(format!(
416            "Access denied. Ensure the service account has RBAC permission \
417                 for secrets in namespace '{namespace}': {e}"
418        )),
419        kube::Error::Api(err_resp) if err_resp.code == 404 => {
420            MemoryError::TokenStorage(format!("Namespace '{namespace}' does not exist: {e}"))
421        }
422        _ => MemoryError::TokenStorage(format!("Kubernetes API error: {e}")),
423    }
424}
425
426// ---------------------------------------------------------------------------
427// Auth status
428// ---------------------------------------------------------------------------
429
430/// Print the current authentication status to stdout.
431///
432/// Shows the source of the resolved token and a redacted preview.
433/// Never prints the full token value.
434pub fn print_auth_status(provider: &AuthProvider) {
435    match provider.resolve_with_source() {
436        Ok((token, source)) => {
437            let raw = token.expose_secret();
438            let preview = if raw.len() >= 8 {
439                format!("...{}", &raw[raw.len() - 4..])
440            } else {
441                "****".to_string()
442            };
443            println!("Authenticated via {source}");
444            println!("Token: {preview}");
445        }
446        Err(_) => {
447            println!("No token configured.");
448            println!("Run `memory-mcp auth login` to authenticate with GitHub.");
449        }
450    }
451}
452
453// ---------------------------------------------------------------------------
454// Permission check (Unix only)
455// ---------------------------------------------------------------------------
456
457/// Warn if the token file has permissions that are wider than 0o600.
458fn check_token_file_permissions(path: &std::path::Path) {
459    #[cfg(unix)]
460    {
461        use std::os::unix::fs::MetadataExt;
462        match std::fs::metadata(path) {
463            Ok(meta) => {
464                let mode = meta.mode() & 0o777;
465                if mode != 0o600 {
466                    warn!(
467                        "token file '{}' has permissions {:04o}; \
468                         expected 0600 — consider running: chmod 600 {}",
469                        path.display(),
470                        mode,
471                        path.display()
472                    );
473                }
474            }
475            Err(e) => {
476                warn!("could not read permissions for '{}': {}", path.display(), e);
477            }
478        }
479    }
480    // On non-Unix platforms there are no POSIX permissions to check.
481    #[cfg(not(unix))]
482    let _ = path;
483}
484
485// ---------------------------------------------------------------------------
486// Platform-portable home directory helper
487// ---------------------------------------------------------------------------
488
489/// Returns the user's home directory using the platform-native mechanism.
490///
491/// Delegates to [`dirs::home_dir`], which checks `HOME` on Unix and the
492/// Windows profile directory on Windows — no deprecated `std::env::home_dir`.
493pub fn home_dir() -> Option<std::path::PathBuf> {
494    dirs::home_dir()
495}
496
497// ---------------------------------------------------------------------------
498// Tests
499// ---------------------------------------------------------------------------
500
501#[cfg(test)]
502mod tests {
503    use std::sync::Mutex;
504
505    use super::*;
506
507    // Serialise all tests that mutate environment variables so they don't race
508    // under `cargo test` (which runs tests in parallel by default).
509    static ENV_LOCK: Mutex<()> = Mutex::new(());
510
511    #[test]
512    fn test_resolve_from_env_var() {
513        let _guard = ENV_LOCK.lock().unwrap();
514        let token_value = "ghp_test_env_token_abc123";
515        std::env::set_var(ENV_VAR, token_value);
516        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
517        std::env::remove_var(ENV_VAR);
518
519        assert!(result.is_ok(), "expected Ok but got: {result:?}");
520        assert_eq!(result.unwrap().expose_secret(), token_value);
521    }
522
523    #[test]
524    fn test_resolve_trims_env_var_whitespace() {
525        let _guard = ENV_LOCK.lock().unwrap();
526        let token_value = "  ghp_padded_token  ";
527        std::env::set_var(ENV_VAR, token_value);
528        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
529        std::env::remove_var(ENV_VAR);
530
531        assert!(result.is_ok());
532        assert_eq!(result.unwrap().expose_secret(), token_value.trim());
533    }
534
535    #[test]
536    fn test_resolve_prefers_env_over_file() {
537        let _guard = ENV_LOCK.lock().unwrap();
538        // Write a token file and simultaneously set the env var; env must win.
539        let dir = tempfile::tempdir().unwrap();
540        let file_path = dir.path().join("token");
541        std::fs::write(&file_path, "ghp_file_token").unwrap();
542
543        let env_token = "ghp_env_wins";
544        std::env::set_var(ENV_VAR, env_token);
545
546        // Override HOME so the file lookup would pick up our temp file if env
547        // were not consulted first.  We rely on env taking precedence, so
548        // this primarily tests ordering rather than actual file resolution.
549        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
550        std::env::remove_var(ENV_VAR);
551
552        assert!(result.is_ok());
553        assert_eq!(result.unwrap().expose_secret(), env_token);
554    }
555
556    #[test]
557    fn test_try_resolve_with_source_returns_env_var_source() {
558        let _guard = ENV_LOCK.lock().unwrap();
559        let token_value = "ghp_source_test_abc";
560        std::env::set_var(ENV_VAR, token_value);
561        let result = AuthProvider::try_resolve_with_source();
562        std::env::remove_var(ENV_VAR);
563
564        assert!(result.is_ok(), "expected Ok but got: {result:?}");
565        let (tok, source) = result.unwrap();
566        assert_eq!(tok.expose_secret(), token_value);
567        assert!(
568            matches!(source, TokenSource::EnvVar),
569            "expected TokenSource::EnvVar, got: {source:?}"
570        );
571    }
572
573    #[test]
574    fn test_store_token_file_backend() {
575        let dir = tempfile::tempdir().unwrap();
576        let token_dir = dir.path().join(".config").join("memory-mcp");
577        let token_path = token_dir.join("token");
578
579        // Temporarily override HOME.
580        let _guard = ENV_LOCK.lock().unwrap();
581        let original_home = std::env::var("HOME").ok();
582        std::env::set_var("HOME", dir.path());
583
584        let result = store_in_file("ghp_file_backend_test");
585
586        // Restore HOME before asserting so other tests aren't affected.
587        match original_home {
588            Some(h) => std::env::set_var("HOME", h),
589            None => std::env::remove_var("HOME"),
590        }
591
592        assert!(result.is_ok(), "store_in_file failed: {result:?}");
593        assert!(token_path.exists(), "token file was not created");
594
595        let content = std::fs::read_to_string(&token_path).unwrap();
596        assert_eq!(content, "ghp_file_backend_test\n");
597
598        // Verify 0o600 permissions on Unix.
599        #[cfg(unix)]
600        {
601            use std::os::unix::fs::MetadataExt;
602            let mode = std::fs::metadata(&token_path).unwrap().mode() & 0o777;
603            assert_eq!(mode, 0o600, "expected 0600 permissions, got {:04o}", mode);
604        }
605    }
606
607    /// This test exercises the keyring path and requires a live D-Bus /
608    /// secret-service backend.  Mark it `#[ignore]` so it does not run in CI.
609    #[test]
610    #[ignore = "requires live system keyring (D-Bus/GNOME Keyring/KWallet)"]
611    fn test_resolve_from_keyring_ignored_in_ci() {
612        let _guard = ENV_LOCK.lock().unwrap();
613        // Pre-condition: no env var, no token file (rely on absence).
614        std::env::remove_var(ENV_VAR);
615
616        // Attempt to store then retrieve; if the keyring is unavailable the
617        // test is inconclusive rather than failing.
618        let entry = keyring::Entry::new("memory-mcp", "github-token")
619            .expect("keyring entry creation should succeed");
620        let test_token = "ghp_keyring_test_token";
621        entry
622            .set_password(test_token)
623            .expect("storing token should succeed");
624
625        let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
626        let _ = entry.delete_credential(); // cleanup before assert
627        assert!(result.is_ok(), "expected token from keyring: {result:?}");
628        assert_eq!(result.unwrap().expose_secret(), test_token);
629    }
630
631    #[cfg(feature = "k8s")]
632    #[test]
633    #[ignore] // Requires a real Kubernetes cluster
634    fn test_store_in_k8s_secret_ignored_in_ci() {
635        // Placeholder for manual/integration testing
636    }
637}