use std::fmt;
use secrecy::{ExposeSecret, SecretString};
use tracing::{debug, info, warn};
use crate::error::MemoryError;
const ENV_VAR: &str = "MEMORY_MCP_GITHUB_TOKEN";
const TOKEN_FILE: &str = ".config/memory-mcp/token";
const GITHUB_CLIENT_ID: &str = "Ov23liWxHYkwXTxCrYHp";
const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
const GITHUB_ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
#[derive(Clone, Debug, clap::ValueEnum)]
#[non_exhaustive]
pub enum StoreBackend {
Keyring,
File,
Stdout,
#[cfg(feature = "k8s")]
#[clap(name = "k8s-secret")]
K8sSecret,
}
#[cfg(feature = "k8s")]
#[derive(Debug)]
pub struct K8sSecretConfig {
pub namespace: String,
pub secret_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum TokenSource {
EnvVar,
File,
Keyring,
Explicit,
}
impl fmt::Display for TokenSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TokenSource::EnvVar => write!(f, "environment variable ({})", ENV_VAR),
TokenSource::File => write!(f, "token file (~/.config/memory-mcp/token)"),
TokenSource::Keyring => write!(f, "system keyring"),
TokenSource::Explicit => write!(f, "explicit token"),
}
}
}
#[derive(serde::Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
expires_in: u64,
interval: u64,
}
#[derive(serde::Deserialize)]
struct AccessTokenResponse {
#[serde(default)]
access_token: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
error_description: Option<String>,
}
pub struct AuthProvider {
token: Option<(SecretString, TokenSource)>,
}
impl AuthProvider {
pub fn new() -> Self {
let token = Self::try_resolve_with_source().ok();
if token.is_some() {
debug!("AuthProvider: token resolved at startup");
} else {
debug!("AuthProvider: no token available at startup");
}
Self { token }
}
pub fn resolve_token(&self) -> Result<SecretString, MemoryError> {
self.resolve_with_source().map(|(tok, _)| tok)
}
pub fn resolve_with_source(&self) -> Result<(SecretString, TokenSource), MemoryError> {
if let Some((ref t, ref s)) = self.token {
return Ok((t.clone(), s.clone()));
}
Self::try_resolve_with_source()
}
fn try_resolve_with_source() -> Result<(SecretString, TokenSource), MemoryError> {
if let Ok(tok) = std::env::var(ENV_VAR) {
if !tok.trim().is_empty() {
return Ok((
SecretString::from(tok.trim().to_string()),
TokenSource::EnvVar,
));
}
}
if let Some(home) = home_dir() {
let path = home.join(TOKEN_FILE);
if path.exists() {
check_token_file_permissions(&path);
let raw = std::fs::read_to_string(&path)?;
let tok = raw.trim().to_string();
if !tok.is_empty() {
return Ok((SecretString::from(tok), TokenSource::File));
}
}
}
match keyring::Entry::new("memory-mcp", "github-token") {
Ok(entry) => match entry.get_password() {
Ok(tok) if !tok.trim().is_empty() => {
info!("resolved GitHub token from system keyring");
return Ok((
SecretString::from(tok.trim().to_string()),
TokenSource::Keyring,
));
}
Ok(_) => { }
Err(keyring::Error::NoEntry) => { }
Err(keyring::Error::NoStorageAccess(_)) => {
debug!("keyring: no storage backend available (headless?)");
}
Err(e) => {
warn!("keyring: unexpected error: {e}");
}
},
Err(e) => {
debug!("keyring: could not create entry: {e}");
}
}
Err(MemoryError::Auth(
"no token available; set MEMORY_MCP_GITHUB_TOKEN, add \
~/.config/memory-mcp/token, or store a token in the system keyring \
under service 'memory-mcp', account 'github-token'."
.to_string(),
))
}
}
impl AuthProvider {
pub fn with_token(token: &str) -> Self {
Self {
token: Some((SecretString::from(token.to_string()), TokenSource::Explicit)),
}
}
}
impl Default for AuthProvider {
fn default() -> Self {
Self::new()
}
}
pub async fn device_flow_login(
store: Option<StoreBackend>,
#[cfg(feature = "k8s")] k8s_config: Option<K8sSecretConfig>,
) -> Result<(), MemoryError> {
use std::time::{Duration, Instant};
use tokio::time::sleep;
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| MemoryError::OAuth(format!("failed to build HTTP client: {e}")))?;
let device_resp = client
.post(GITHUB_DEVICE_CODE_URL)
.header("Accept", "application/json")
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
.send()
.await
.map_err(|e| {
MemoryError::OAuth(format!(
"failed to contact GitHub device code endpoint: {e}"
))
})?
.error_for_status()
.map_err(|e| MemoryError::OAuth(format!("GitHub device code request failed: {e}")))?
.json::<DeviceCodeResponse>()
.await
.map_err(|e| MemoryError::OAuth(format!("failed to parse device code response: {e}")))?;
let expires_in = device_resp.expires_in.min(1800);
let deadline = Instant::now() + Duration::from_secs(expires_in);
eprintln!();
eprintln!(" Open this URL in your browser:");
eprintln!(" {}", device_resp.verification_uri);
eprintln!();
eprintln!(" Enter this code when prompted:");
eprintln!(" {}", device_resp.user_code);
eprintln!();
eprintln!(" Waiting for authorization...");
let mut poll_interval = device_resp.interval.clamp(1, 30);
let token = loop {
if Instant::now() >= deadline {
return Err(MemoryError::OAuth(format!(
"Device code expired after {expires_in} seconds"
)));
}
sleep(Duration::from_secs(poll_interval)).await;
let resp = client
.post(GITHUB_ACCESS_TOKEN_URL)
.header("Accept", "application/json")
.form(&[
("client_id", GITHUB_CLIENT_ID),
("device_code", &device_resp.device_code),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
])
.send()
.await
.map_err(|e| MemoryError::OAuth(format!("polling GitHub token endpoint failed: {e}")))?
.error_for_status()
.map_err(|e| {
MemoryError::OAuth(format!("GitHub token request returned error status: {e}"))
})?
.json::<AccessTokenResponse>()
.await
.map_err(|e| MemoryError::OAuth(format!("failed to parse token response: {e}")))?;
if let Some(tok) = resp.access_token.filter(|t| !t.trim().is_empty()) {
break SecretString::from(tok);
}
match resp.error.as_deref() {
Some("authorization_pending") => {
continue;
}
Some("slow_down") => {
poll_interval = (poll_interval + 5).min(60);
continue;
}
Some("expired_token") => {
return Err(MemoryError::OAuth(
"device code expired; please run `memory-mcp auth login` again".to_string(),
));
}
Some("access_denied") => {
return Err(MemoryError::OAuth(
"authorization denied by user".to_string(),
));
}
Some(other) => {
let desc = resp
.error_description
.as_deref()
.unwrap_or("no description");
return Err(MemoryError::OAuth(format!(
"unexpected OAuth error '{other}': {desc}"
)));
}
None => {
return Err(MemoryError::OAuth(
"GitHub returned neither an access_token nor an error field; \
unexpected response"
.to_string(),
));
}
}
};
store_token(
&token,
store,
#[cfg(feature = "k8s")]
k8s_config,
)
.await?;
eprintln!("Authentication successful.");
Ok(())
}
async fn store_token(
token: &SecretString,
backend: Option<StoreBackend>,
#[cfg(feature = "k8s")] k8s_config: Option<K8sSecretConfig>,
) -> Result<(), MemoryError> {
match backend {
Some(StoreBackend::Stdout) => {
println!("{}", token.expose_secret());
debug!("token written to stdout");
}
Some(StoreBackend::Keyring) => {
store_in_keyring(token.expose_secret())?;
}
Some(StoreBackend::File) => {
store_in_file(token.expose_secret())?;
}
#[cfg(feature = "k8s")]
Some(StoreBackend::K8sSecret) => {
let config = k8s_config.ok_or_else(|| {
MemoryError::TokenStorage(
"k8s-secret backend requires namespace and secret name".into(),
)
})?;
store_in_k8s_secret(token.expose_secret(), &config).await?;
}
None => {
store_in_keyring(token.expose_secret()).map_err(|e| {
MemoryError::TokenStorage(format!(
"Keyring unavailable: {e}. Use --store file to write to \
~/.config/memory-mcp/token, --store stdout to print the token\
{k8s_hint}.",
k8s_hint = if cfg!(feature = "k8s") {
", or --store k8s-secret to store in a Kubernetes Secret"
} else {
""
}
))
})?;
}
}
Ok(())
}
fn store_in_keyring(token: &str) -> Result<(), MemoryError> {
let entry = keyring::Entry::new("memory-mcp", "github-token")
.map_err(|e| MemoryError::TokenStorage(format!("failed to create keyring entry: {e}")))?;
entry
.set_password(token)
.map_err(|e| MemoryError::TokenStorage(format!("failed to store token in keyring: {e}")))?;
info!("token stored in system keyring");
Ok(())
}
fn store_in_file(token: &str) -> Result<(), MemoryError> {
let home =
home_dir().ok_or_else(|| MemoryError::TokenStorage("HOME directory is not set".into()))?;
let token_path = home.join(TOKEN_FILE);
if let Some(parent) = token_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
MemoryError::TokenStorage(format!(
"failed to create config directory {}: {e}",
parent.display()
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).map_err(
|e| {
MemoryError::TokenStorage(format!(
"failed to set config directory permissions: {e}"
))
},
)?;
}
}
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let parent = token_path.parent().expect("token_path always has a parent");
let tmp_path = parent.join(".token.tmp");
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp_path)
.map_err(|e| {
MemoryError::TokenStorage(format!("failed to open temp token file: {e}"))
})?;
f.write_all(token.as_bytes()).map_err(|e| {
MemoryError::TokenStorage(format!("failed to write temp token file: {e}"))
})?;
f.write_all(b"\n").map_err(|e| {
MemoryError::TokenStorage(format!("failed to write temp token file: {e}"))
})?;
f.sync_all().map_err(|e| {
MemoryError::TokenStorage(format!("failed to sync temp token file: {e}"))
})?;
drop(f);
std::fs::rename(&tmp_path, &token_path).map_err(|e| {
MemoryError::TokenStorage(format!("failed to rename token file into place: {e}"))
})?;
}
#[cfg(not(unix))]
{
std::fs::write(&token_path, format!("{token}\n"))
.map_err(|e| MemoryError::TokenStorage(format!("failed to write token file: {e}")))?;
}
info!("token stored in file ({})", token_path.display());
Ok(())
}
#[cfg(feature = "k8s")]
async fn store_in_k8s_secret(token: &str, config: &K8sSecretConfig) -> Result<(), MemoryError> {
use k8s_openapi::api::core::v1::Secret;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use kube::{api::PostParams, Api, Client};
use std::collections::BTreeMap;
let client = Client::try_default().await.map_err(|e| {
MemoryError::TokenStorage(format!(
"Failed to initialize Kubernetes client. Ensure KUBECONFIG is set \
or the pod has a service account: {e}"
))
})?;
let secrets: Api<Secret> = Api::namespaced(client, &config.namespace);
let secret_name = &config.secret_name;
let mut data = BTreeMap::new();
data.insert(
"token".to_string(),
k8s_openapi::ByteString(token.as_bytes().to_vec()),
);
let mut labels = BTreeMap::new();
labels.insert(
"app.kubernetes.io/managed-by".to_string(),
"memory-mcp".to_string(),
);
labels.insert(
"app.kubernetes.io/component".to_string(),
"auth".to_string(),
);
let mut secret = Secret {
metadata: ObjectMeta {
name: Some(secret_name.clone()),
namespace: Some(config.namespace.clone()),
labels: Some(labels),
..Default::default()
},
data: Some(data),
type_: Some("Opaque".to_string()),
..Default::default()
};
match secrets.create(&PostParams::default(), &secret).await {
Ok(_) => {
debug!(
"created Kubernetes Secret '{secret_name}' in namespace '{}'",
config.namespace
);
}
Err(kube::Error::Api(ref err_resp)) if err_resp.code == 409 => {
let existing = secrets
.get(secret_name)
.await
.map_err(|e| map_kube_error(e, &config.namespace))?;
secret.metadata.resource_version = existing.metadata.resource_version;
secrets
.replace(secret_name, &PostParams::default(), &secret)
.await
.map_err(|e| map_kube_error(e, &config.namespace))?;
debug!(
"updated Kubernetes Secret '{secret_name}' in namespace '{}'",
config.namespace
);
}
Err(e) => {
return Err(map_kube_error(e, &config.namespace));
}
}
eprintln!(
"Token stored in Kubernetes Secret '{secret_name}' (namespace: {})",
config.namespace
);
Ok(())
}
#[cfg(feature = "k8s")]
fn map_kube_error(e: kube::Error, namespace: &str) -> MemoryError {
match &e {
kube::Error::Api(err_resp) if err_resp.code == 403 => MemoryError::TokenStorage(format!(
"Access denied. Ensure the service account has RBAC permission \
for secrets in namespace '{namespace}': {e}"
)),
kube::Error::Api(err_resp) if err_resp.code == 404 => {
MemoryError::TokenStorage(format!("Namespace '{namespace}' does not exist: {e}"))
}
_ => MemoryError::TokenStorage(format!("Kubernetes API error: {e}")),
}
}
pub fn print_auth_status(provider: &AuthProvider) {
match provider.resolve_with_source() {
Ok((token, source)) => {
let raw = token.expose_secret();
let preview = if raw.len() >= 8 {
format!("...{}", &raw[raw.len() - 4..])
} else {
"****".to_string()
};
println!("Authenticated via {source}");
println!("Token: {preview}");
}
Err(_) => {
println!("No token configured.");
println!("Run `memory-mcp auth login` to authenticate with GitHub.");
}
}
}
fn check_token_file_permissions(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
match std::fs::metadata(path) {
Ok(meta) => {
let mode = meta.mode() & 0o777;
if mode != 0o600 {
warn!(
"token file '{}' has permissions {:04o}; \
expected 0600 — consider running: chmod 600 {}",
path.display(),
mode,
path.display()
);
}
}
Err(e) => {
warn!("could not read permissions for '{}': {}", path.display(), e);
}
}
}
#[cfg(not(unix))]
let _ = path;
}
pub fn home_dir() -> Option<std::path::PathBuf> {
dirs::home_dir()
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn test_resolve_from_env_var() {
let _guard = ENV_LOCK.lock().unwrap();
let token_value = "ghp_test_env_token_abc123";
std::env::set_var(ENV_VAR, token_value);
let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
std::env::remove_var(ENV_VAR);
assert!(result.is_ok(), "expected Ok but got: {result:?}");
assert_eq!(result.unwrap().expose_secret(), token_value);
}
#[test]
fn test_resolve_trims_env_var_whitespace() {
let _guard = ENV_LOCK.lock().unwrap();
let token_value = " ghp_padded_token ";
std::env::set_var(ENV_VAR, token_value);
let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
std::env::remove_var(ENV_VAR);
assert!(result.is_ok());
assert_eq!(result.unwrap().expose_secret(), token_value.trim());
}
#[test]
fn test_resolve_prefers_env_over_file() {
let _guard = ENV_LOCK.lock().unwrap();
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("token");
std::fs::write(&file_path, "ghp_file_token").unwrap();
let env_token = "ghp_env_wins";
std::env::set_var(ENV_VAR, env_token);
let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
std::env::remove_var(ENV_VAR);
assert!(result.is_ok());
assert_eq!(result.unwrap().expose_secret(), env_token);
}
#[test]
fn test_try_resolve_with_source_returns_env_var_source() {
let _guard = ENV_LOCK.lock().unwrap();
let token_value = "ghp_source_test_abc";
std::env::set_var(ENV_VAR, token_value);
let result = AuthProvider::try_resolve_with_source();
std::env::remove_var(ENV_VAR);
assert!(result.is_ok(), "expected Ok but got: {result:?}");
let (tok, source) = result.unwrap();
assert_eq!(tok.expose_secret(), token_value);
assert!(
matches!(source, TokenSource::EnvVar),
"expected TokenSource::EnvVar, got: {source:?}"
);
}
#[test]
fn test_store_token_file_backend() {
let dir = tempfile::tempdir().unwrap();
let token_dir = dir.path().join(".config").join("memory-mcp");
let token_path = token_dir.join("token");
let _guard = ENV_LOCK.lock().unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", dir.path());
let result = store_in_file("ghp_file_backend_test");
match original_home {
Some(h) => std::env::set_var("HOME", h),
None => std::env::remove_var("HOME"),
}
assert!(result.is_ok(), "store_in_file failed: {result:?}");
assert!(token_path.exists(), "token file was not created");
let content = std::fs::read_to_string(&token_path).unwrap();
assert_eq!(content, "ghp_file_backend_test\n");
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let mode = std::fs::metadata(&token_path).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600, "expected 0600 permissions, got {:04o}", mode);
}
}
#[test]
#[ignore = "requires live system keyring (D-Bus/GNOME Keyring/KWallet)"]
fn test_resolve_from_keyring_ignored_in_ci() {
let _guard = ENV_LOCK.lock().unwrap();
std::env::remove_var(ENV_VAR);
let entry = keyring::Entry::new("memory-mcp", "github-token")
.expect("keyring entry creation should succeed");
let test_token = "ghp_keyring_test_token";
entry
.set_password(test_token)
.expect("storing token should succeed");
let result = AuthProvider::try_resolve_with_source().map(|(tok, _)| tok);
let _ = entry.delete_credential(); assert!(result.is_ok(), "expected token from keyring: {result:?}");
assert_eq!(result.unwrap().expose_secret(), test_token);
}
#[tokio::test]
#[ignore = "requires real GitHub OAuth interaction"]
async fn test_device_flow_login_ignored_in_ci() {
device_flow_login(
Some(StoreBackend::Stdout),
#[cfg(feature = "k8s")]
None,
)
.await
.expect("device flow should succeed");
}
#[cfg(feature = "k8s")]
#[test]
#[ignore] fn test_store_in_k8s_secret_ignored_in_ci() {
}
}