use anyhow::{Context, Result, anyhow};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use mermaid_model::utils::CredentialStore;
use serde::{Deserialize, Serialize};
const CHUNK_CHARS: usize = 1000;
const MAX_CHUNKS: usize = 64;
const CHUNK_PREFIX: &str = "chunks:";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ClientAuth {
#[default]
None,
ClientSecretBasic,
ClientSecretPost,
}
#[derive(Clone, Serialize, Deserialize)]
pub(crate) struct StoredTokens {
pub server_url: String,
pub resource: String,
pub issuer: String,
pub token_endpoint: String,
pub client_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_secret: Option<String>,
#[serde(default)]
pub client_auth: ClientAuth,
pub access_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
fn account(server: &str) -> String {
format!("mcp-oauth:{server}")
}
fn chunk_account(server: &str, index: usize) -> String {
format!("mcp-oauth:{server}#{index}")
}
pub(crate) fn load(store: &dyn CredentialStore, server: &str) -> Option<StoredTokens> {
let head = store.get(&account(server))?;
let encoded = match head.strip_prefix(CHUNK_PREFIX) {
None => head,
Some(count) => {
let count: usize = count
.parse()
.ok()
.filter(|n| (1..=MAX_CHUNKS).contains(n))?;
let mut joined = String::new();
for index in 1..=count {
joined.push_str(&store.get(&chunk_account(server, index))?);
}
joined
},
};
let bytes = URL_SAFE_NO_PAD.decode(encoded.as_bytes()).ok()?;
match serde_json::from_slice(&bytes) {
Ok(tokens) => Some(tokens),
Err(e) => {
tracing::warn!("MCP OAuth: stored tokens for '{server}' do not parse: {e}");
None
},
}
}
pub(crate) fn save(store: &dyn CredentialStore, server: &str, tokens: &StoredTokens) -> Result<()> {
let old_chunks = chunk_count(store, server);
let encoded = URL_SAFE_NO_PAD.encode(serde_json::to_vec(tokens)?);
let new_chunks = if encoded.len() <= CHUNK_CHARS {
store.set(&account(server), &encoded)?;
0
} else {
let parts: Vec<&str> = encoded
.as_bytes()
.chunks(CHUNK_CHARS)
.map(|c| std::str::from_utf8(c).expect("base64 is ASCII"))
.collect();
if parts.len() > MAX_CHUNKS {
return Err(anyhow!(
"MCP OAuth tokens for '{server}' are too large to store"
));
}
for (i, part) in parts.iter().enumerate() {
store.set(&chunk_account(server, i + 1), part)?;
}
store.set(&account(server), &format!("{CHUNK_PREFIX}{}", parts.len()))?;
parts.len()
};
for index in (new_chunks + 1)..=old_chunks {
let _ = store.delete(&chunk_account(server, index));
}
Ok(())
}
pub(crate) fn delete(store: &dyn CredentialStore, server: &str) -> Result<bool> {
let chunks = chunk_count(store, server);
let removed = store
.delete(&account(server))
.with_context(|| format!("delete MCP OAuth tokens for '{server}'"))?;
for index in 1..=chunks {
let _ = store.delete(&chunk_account(server, index));
}
Ok(removed)
}
fn chunk_count(store: &dyn CredentialStore, server: &str) -> usize {
store
.get(&account(server))
.and_then(|head| head.strip_prefix(CHUNK_PREFIX)?.parse().ok())
.filter(|n| *n <= MAX_CHUNKS)
.unwrap_or(0)
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Default)]
pub struct MemStore {
pub entries: Mutex<HashMap<String, String>>,
}
impl CredentialStore for MemStore {
fn get(&self, account: &str) -> Option<String> {
self.entries.lock().unwrap().get(account).cloned()
}
fn set(&self, account: &str, value: &str) -> Result<()> {
anyhow::ensure!(value.len() <= 1280, "entry too long for Windows");
self.entries
.lock()
.unwrap()
.insert(account.to_string(), value.to_string());
Ok(())
}
fn delete(&self, account: &str) -> Result<bool> {
Ok(self.entries.lock().unwrap().remove(account).is_some())
}
fn label(&self) -> &'static str {
"memory store"
}
}
pub fn tokens(access: &str) -> StoredTokens {
StoredTokens {
server_url: "http://127.0.0.1/mcp".to_string(),
resource: "http://127.0.0.1/mcp".to_string(),
issuer: "http://127.0.0.1".to_string(),
token_endpoint: "http://127.0.0.1/token".to_string(),
client_id: "client-1".to_string(),
client_secret: None,
client_auth: ClientAuth::None,
access_token: access.to_string(),
refresh_token: Some("refresh-1".to_string()),
expires_at: None,
scope: Some("files:read files:write".to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::*;
use super::*;
#[test]
fn short_record_round_trips_in_one_entry() {
let store = MemStore::default();
save(&store, "s", &tokens("abc")).unwrap();
assert_eq!(store.entries.lock().unwrap().len(), 1);
let back = load(&store, "s").expect("stored");
assert_eq!(back.access_token, "abc");
assert_eq!(back.scope.as_deref(), Some("files:read files:write"));
}
#[test]
fn long_record_is_split_and_shrinking_removes_stale_parts() {
let store = MemStore::default();
let big = "x".repeat(4000);
save(&store, "s", &tokens(&big)).unwrap();
let entries = store.entries.lock().unwrap().len();
assert!(entries > 2, "split into parts: {entries}");
assert_eq!(load(&store, "s").unwrap().access_token, big);
save(&store, "s", &tokens("short")).unwrap();
assert_eq!(store.entries.lock().unwrap().len(), 1);
assert_eq!(load(&store, "s").unwrap().access_token, "short");
}
#[test]
fn delete_removes_every_part() {
let store = MemStore::default();
save(&store, "s", &tokens(&"y".repeat(3000))).unwrap();
assert!(delete(&store, "s").unwrap());
assert!(store.entries.lock().unwrap().is_empty());
assert!(!delete(&store, "s").unwrap());
assert!(load(&store, "s").is_none());
}
#[test]
fn corrupt_record_reads_as_none() {
let store = MemStore::default();
store.set("mcp-oauth:s", "not base64 json!").unwrap();
assert!(load(&store, "s").is_none());
store.set("mcp-oauth:s", "chunks:3").unwrap();
assert!(load(&store, "s").is_none(), "missing parts");
}
}