1use std::fmt;
2
3use secrecy::{ExposeSecret, SecretString};
4use tracing::{debug, info, warn};
5
6use crate::error::MemoryError;
7
8pub mod oauth;
10pub use oauth::{device_flow_login, DeviceFlowProvider, GitHubDeviceFlow};
11
12const ENV_VAR: &str = "MEMORY_MCP_GITHUB_TOKEN";
17const TOKEN_FILE: &str = ".config/memory-mcp/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
82pub struct AuthProvider {
88 token: Option<(SecretString, TokenSource)>,
90}
91
92impl AuthProvider {
93 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 pub fn resolve_token(&self) -> Result<SecretString, MemoryError> {
115 self.resolve_with_source().map(|(tok, _)| tok)
116 }
117
118 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 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 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 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_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 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(_) => { }
186 Err(keyring::Error::NoEntry) => { }
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 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
227pub(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 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 #[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 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#[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 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 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
426pub 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
453fn 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 #[cfg(not(unix))]
482 let _ = path;
483}
484
485pub fn home_dir() -> Option<std::path::PathBuf> {
494 dirs::home_dir()
495}
496
497#[cfg(test)]
502mod tests {
503 use std::sync::Mutex;
504
505 use super::*;
506
507 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 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 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 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 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 #[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 #[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 std::env::remove_var(ENV_VAR);
615
616 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(); 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] fn test_store_in_k8s_secret_ignored_in_ci() {
635 }
637}