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 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 if let Some(home) = home_dir() {
171 let path = home.join(TOKEN_FILE);
172 if path.exists() {
173 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 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(_) => { }
195 Err(keyring::Error::NoEntry) => { }
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 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
235pub 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 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 let expires_in = device_resp.expires_in.min(1800);
276 let deadline = Instant::now() + Duration::from_secs(expires_in);
277
278 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 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 continue;
326 }
327 Some("slow_down") => {
328 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 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
374async 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 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 #[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 crate::fs_util::atomic_write(&token_path, format!("{token}\n").as_bytes())
466 .map_err(|e| MemoryError::TokenStorage(format!("failed to write token file: {e}")))?;
467
468 info!("token stored in file ({})", token_path.display());
469 Ok(())
470}
471
472#[cfg(feature = "k8s")]
477async fn store_in_k8s_secret(token: &str, config: &K8sSecretConfig) -> Result<(), MemoryError> {
478 use k8s_openapi::api::core::v1::Secret;
479 use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
480 use kube::{api::PostParams, Api, Client};
481 use std::collections::BTreeMap;
482
483 let client = Client::try_default().await.map_err(|e| {
484 MemoryError::TokenStorage(format!(
485 "Failed to initialize Kubernetes client. Ensure KUBECONFIG is set \
486 or the pod has a service account: {e}"
487 ))
488 })?;
489
490 let secrets: Api<Secret> = Api::namespaced(client, &config.namespace);
491 let secret_name = &config.secret_name;
492
493 let mut data = BTreeMap::new();
494 data.insert(
495 "token".to_string(),
496 k8s_openapi::ByteString(token.as_bytes().to_vec()),
497 );
498
499 let mut labels = BTreeMap::new();
500 labels.insert(
501 "app.kubernetes.io/managed-by".to_string(),
502 "memory-mcp".to_string(),
503 );
504 labels.insert(
505 "app.kubernetes.io/component".to_string(),
506 "auth".to_string(),
507 );
508
509 let mut secret = Secret {
510 metadata: ObjectMeta {
511 name: Some(secret_name.clone()),
512 namespace: Some(config.namespace.clone()),
513 labels: Some(labels),
514 ..Default::default()
515 },
516 data: Some(data),
517 type_: Some("Opaque".to_string()),
518 ..Default::default()
519 };
520
521 match secrets.create(&PostParams::default(), &secret).await {
525 Ok(_) => {
526 debug!(
527 "created Kubernetes Secret '{secret_name}' in namespace '{}'",
528 config.namespace
529 );
530 }
531 Err(kube::Error::Api(ref err_resp)) if err_resp.code == 409 => {
532 let existing = secrets
534 .get(secret_name)
535 .await
536 .map_err(|e| map_kube_error(e, &config.namespace))?;
537 secret.metadata.resource_version = existing.metadata.resource_version;
538 secrets
539 .replace(secret_name, &PostParams::default(), &secret)
540 .await
541 .map_err(|e| map_kube_error(e, &config.namespace))?;
542 debug!(
543 "updated Kubernetes Secret '{secret_name}' in namespace '{}'",
544 config.namespace
545 );
546 }
547 Err(e) => {
548 return Err(map_kube_error(e, &config.namespace));
549 }
550 }
551
552 eprintln!(
553 "Token stored in Kubernetes Secret '{secret_name}' (namespace: {})",
554 config.namespace
555 );
556 Ok(())
557}
558
559#[cfg(feature = "k8s")]
560fn map_kube_error(e: kube::Error, namespace: &str) -> MemoryError {
561 match &e {
562 kube::Error::Api(err_resp) if err_resp.code == 403 => MemoryError::TokenStorage(format!(
563 "Access denied. Ensure the service account has RBAC permission \
564 for secrets in namespace '{namespace}': {e}"
565 )),
566 kube::Error::Api(err_resp) if err_resp.code == 404 => {
567 MemoryError::TokenStorage(format!("Namespace '{namespace}' does not exist: {e}"))
568 }
569 _ => MemoryError::TokenStorage(format!("Kubernetes API error: {e}")),
570 }
571}
572
573pub fn print_auth_status(provider: &AuthProvider) {
582 match provider.resolve_with_source() {
583 Ok((token, source)) => {
584 let raw = token.expose_secret();
585 let preview = if raw.len() >= 8 {
586 format!("...{}", &raw[raw.len() - 4..])
587 } else {
588 "****".to_string()
589 };
590 println!("Authenticated via {source}");
591 println!("Token: {preview}");
592 }
593 Err(_) => {
594 println!("No token configured.");
595 println!("Run `memory-mcp auth login` to authenticate with GitHub.");
596 }
597 }
598}
599
600fn check_token_file_permissions(path: &std::path::Path) {
606 #[cfg(unix)]
607 {
608 use std::os::unix::fs::MetadataExt;
609 match std::fs::metadata(path) {
610 Ok(meta) => {
611 let mode = meta.mode() & 0o777;
612 if mode != 0o600 {
613 warn!(
614 "token file '{}' has permissions {:04o}; \
615 expected 0600 — consider running: chmod 600 {}",
616 path.display(),
617 mode,
618 path.display()
619 );
620 }
621 }
622 Err(e) => {
623 warn!("could not read permissions for '{}': {}", path.display(), e);
624 }
625 }
626 }
627 #[cfg(not(unix))]
629 let _ = path;
630}
631
632pub fn home_dir() -> Option<std::path::PathBuf> {
641 dirs::home_dir()
642}
643
644#[cfg(test)]
649mod tests {
650 use std::sync::Mutex;
651
652 use super::*;
653
654 static ENV_LOCK: Mutex<()> = Mutex::new(());
657
658 #[test]
659 fn test_resolve_from_env_var() {
660 let _guard = ENV_LOCK.lock().unwrap();
661 let token_value = "ghp_test_env_token_abc123";
662 std::env::set_var(ENV_VAR, token_value);
663 let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
664 std::env::remove_var(ENV_VAR);
665
666 assert!(result.is_ok(), "expected Ok but got: {result:?}");
667 assert_eq!(result.unwrap().expose_secret(), token_value);
668 }
669
670 #[test]
671 fn test_resolve_trims_env_var_whitespace() {
672 let _guard = ENV_LOCK.lock().unwrap();
673 let token_value = " ghp_padded_token ";
674 std::env::set_var(ENV_VAR, token_value);
675 let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
676 std::env::remove_var(ENV_VAR);
677
678 assert!(result.is_ok());
679 assert_eq!(result.unwrap().expose_secret(), token_value.trim());
680 }
681
682 #[test]
683 fn test_resolve_prefers_env_over_file() {
684 let _guard = ENV_LOCK.lock().unwrap();
685 let dir = tempfile::tempdir().unwrap();
687 let file_path = dir.path().join("token");
688 std::fs::write(&file_path, "ghp_file_token").unwrap();
689
690 let env_token = "ghp_env_wins";
691 std::env::set_var(ENV_VAR, env_token);
692
693 let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
697 std::env::remove_var(ENV_VAR);
698
699 assert!(result.is_ok());
700 assert_eq!(result.unwrap().expose_secret(), env_token);
701 }
702
703 #[test]
704 fn test_try_resolve_with_source_returns_env_var_source() {
705 let _guard = ENV_LOCK.lock().unwrap();
706 let token_value = "ghp_source_test_abc";
707 std::env::set_var(ENV_VAR, token_value);
708 let result = AuthProvider::try_resolve_with_source();
709 std::env::remove_var(ENV_VAR);
710
711 assert!(result.is_ok(), "expected Ok but got: {result:?}");
712 let (tok, source) = result.unwrap();
713 assert_eq!(tok.expose_secret(), token_value);
714 assert!(
715 matches!(source, TokenSource::EnvVar),
716 "expected TokenSource::EnvVar, got: {source:?}"
717 );
718 }
719
720 #[test]
721 fn test_store_token_file_backend() {
722 let dir = tempfile::tempdir().unwrap();
723 let token_dir = dir.path().join(".config").join("memory-mcp");
724 let token_path = token_dir.join("token");
725
726 let _guard = ENV_LOCK.lock().unwrap();
728 let original_home = std::env::var("HOME").ok();
729 std::env::set_var("HOME", dir.path());
730
731 let result = store_in_file("ghp_file_backend_test");
732
733 match original_home {
735 Some(h) => std::env::set_var("HOME", h),
736 None => std::env::remove_var("HOME"),
737 }
738
739 assert!(result.is_ok(), "store_in_file failed: {result:?}");
740 assert!(token_path.exists(), "token file was not created");
741
742 let content = std::fs::read_to_string(&token_path).unwrap();
743 assert_eq!(content, "ghp_file_backend_test\n");
744
745 #[cfg(unix)]
747 {
748 use std::os::unix::fs::MetadataExt;
749 let mode = std::fs::metadata(&token_path).unwrap().mode() & 0o777;
750 assert_eq!(mode, 0o600, "expected 0600 permissions, got {:04o}", mode);
751 }
752 }
753
754 #[test]
757 #[ignore = "requires live system keyring (D-Bus/GNOME Keyring/KWallet)"]
758 fn test_resolve_from_keyring_ignored_in_ci() {
759 let _guard = ENV_LOCK.lock().unwrap();
760 std::env::remove_var(ENV_VAR);
762
763 let entry = keyring::Entry::new("memory-mcp", "github-token")
766 .expect("keyring entry creation should succeed");
767 let test_token = "ghp_keyring_test_token";
768 entry
769 .set_password(test_token)
770 .expect("storing token should succeed");
771
772 let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
773 let _ = entry.delete_credential(); assert!(result.is_ok(), "expected token from keyring: {result:?}");
775 assert_eq!(result.unwrap().expose_secret(), test_token);
776 }
777
778 #[tokio::test]
780 #[ignore = "requires real GitHub OAuth interaction"]
781 async fn test_device_flow_login_ignored_in_ci() {
782 device_flow_login(
783 Some(StoreBackend::Stdout),
784 #[cfg(feature = "k8s")]
785 None,
786 )
787 .await
788 .expect("device flow should succeed");
789 }
790
791 #[cfg(feature = "k8s")]
792 #[test]
793 #[ignore] fn test_store_in_k8s_secret_ignored_in_ci() {
795 }
797}