1use std::fmt;
2
3use secrecy::{ExposeSecret, SecretString};
4use tracing::{debug, info, warn};
5
6use crate::error::MemoryError;
7
8const 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#[derive(Clone, Debug, clap::ValueEnum)]
25#[non_exhaustive]
26pub enum StoreBackend {
27 Keyring,
29 File,
31 Stdout,
33 #[cfg(feature = "k8s")]
35 #[clap(name = "k8s-secret")]
36 K8sSecret,
37}
38
39#[cfg(feature = "k8s")]
45#[derive(Debug)]
46pub struct K8sSecretConfig {
47 pub namespace: String,
49 pub secret_name: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub enum TokenSource {
61 EnvVar,
63 File,
65 Keyring,
67 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#[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
105pub struct AuthProvider {
111 token: Option<(SecretString, TokenSource)>,
113}
114
115impl AuthProvider {
116 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 pub fn resolve_token(&self) -> Result<SecretString, MemoryError> {
138 self.resolve_with_source().map(|(tok, _)| tok)
139 }
140
141 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 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 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 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_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 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(_) => { }
209 Err(keyring::Error::NoEntry) => { }
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 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
250pub 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 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 let expires_in = device_resp.expires_in.min(1800);
291 let deadline = Instant::now() + Duration::from_secs(expires_in);
292
293 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 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 continue;
341 }
342 Some("slow_down") => {
343 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 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
389async 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 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 #[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 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#[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 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 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
588pub 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
615fn 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 #[cfg(not(unix))]
644 let _ = path;
645}
646
647pub fn home_dir() -> Option<std::path::PathBuf> {
656 dirs::home_dir()
657}
658
659#[cfg(test)]
664mod tests {
665 use std::sync::Mutex;
666
667 use super::*;
668
669 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 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 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 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 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 #[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 #[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 std::env::remove_var(ENV_VAR);
777
778 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(); assert!(result.is_ok(), "expected token from keyring: {result:?}");
790 assert_eq!(result.unwrap().expose_secret(), test_token);
791 }
792
793 #[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] fn test_store_in_k8s_secret_ignored_in_ci() {
810 }
812}