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 #[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#[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 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 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
605pub 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
632fn 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 #[cfg(not(unix))]
661 let _ = path;
662}
663
664pub fn home_dir() -> Option<std::path::PathBuf> {
673 homedir::my_home().ok().flatten()
674}
675
676#[cfg(test)]
681mod tests {
682 use std::sync::Mutex;
683
684 use super::*;
685
686 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 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 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 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 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 #[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 #[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 std::env::remove_var(ENV_VAR);
794
795 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(); assert!(result.is_ok(), "expected token from keyring: {result:?}");
807 assert_eq!(result.unwrap().expose_secret(), test_token);
808 }
809
810 #[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] fn test_store_in_k8s_secret_ignored_in_ci() {
827 }
829}