use super::*;
use crate::http_body::{DEFAULT_BOUNDED_BODY_MAX_BYTES, read_bounded_response_text};
#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub(crate) struct TokenResponse {
pub(crate) access_token: String,
#[serde(default)]
pub(crate) token_type: String,
#[serde(default)]
pub(crate) expires_in: Option<u64>,
#[serde(default)]
pub(crate) refresh_token: Option<String>,
#[serde(default)]
pub(crate) scope: Option<String>,
#[serde(default, flatten)]
pub(crate) extra: BTreeMap<String, Value>,
}
impl fmt::Debug for TokenResponse {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TokenResponse")
.field("access_token", &"[REDACTED]")
.field("token_type", &self.token_type)
.field("expires_in", &self.expires_in)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
)
.field("scope", &self.scope)
.field("extra", &self.extra)
.finish()
}
}
#[derive(Debug, Deserialize)]
struct OAuthErrorResponse {
error: Option<String>,
}
pub(super) fn token_error_message(
status: reqwest::StatusCode,
body: &str,
refresh: bool,
) -> String {
let parsed = serde_json::from_str::<OAuthErrorResponse>(body).ok();
let code = parsed
.as_ref()
.and_then(|error| error.error.as_deref())
.filter(|code| is_known_oauth_error_code(code))
.unwrap_or_default();
match (refresh, code) {
(false, "invalid_grant") => {
"authorization code invalid or expired; try mcp login again".to_string()
}
(false, "invalid_client") => "client_id rejected by server".to_string(),
(true, "invalid_grant") => {
"refresh token expired or revoked; run magi-code mcp login <server>".to_string()
}
(_, "") if parsed.is_some() => "OAuth token exchange failed".to_string(),
(_, "") => format!("MCP OAuth token endpoint returned HTTP {status}"),
(_, code) => format!("MCP OAuth token endpoint returned OAuth error {code}"),
}
}
fn is_known_oauth_error_code(code: &str) -> bool {
matches!(
code,
"invalid_request"
| "invalid_client"
| "invalid_grant"
| "unauthorized_client"
| "unsupported_grant_type"
| "invalid_scope"
| "server_error"
| "temporarily_unavailable"
)
}
#[derive(Clone)]
pub(crate) struct TokenProvider {
inner: Arc<Mutex<TokenProviderInner>>,
}
#[derive(Clone, Debug)]
struct TokenProviderInner {
mc_home: PathBuf,
server_name: String,
server_url: String,
oauth: McpOAuthConfig,
client: reqwest::blocking::Client,
}
impl TokenProvider {
pub(crate) fn new(
mc_home: PathBuf,
server_name: String,
server_url: String,
oauth: McpOAuthConfig,
client: reqwest::blocking::Client,
) -> Self {
Self {
inner: Arc::new(Mutex::new(TokenProviderInner {
mc_home,
server_name,
server_url,
oauth,
client,
})),
}
}
pub(crate) fn access_token(&self) -> McpResult<String> {
self.with_inner(false)
}
pub(crate) fn force_refresh_access_token(&self) -> McpResult<String> {
self.with_inner(true)
}
fn with_inner(&self, force_refresh: bool) -> McpResult<String> {
let inner = self
.inner
.lock()
.map_err(|_| McpError::Transport("MCP OAuth token provider lock poisoned".to_string()))?
.clone();
inner.access_token(force_refresh)
}
}
impl std::fmt::Debug for TokenProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TokenProvider").finish_non_exhaustive()
}
}
impl TokenProviderInner {
fn access_token(&self, force_refresh: bool) -> McpResult<String> {
loop {
let token = read_token_locked(&self.mc_home, &self.server_name)?.ok_or_else(|| {
McpError::Config(format!(
"not authenticated; run magi-code mcp login {}",
self.server_name
))
})?;
self.validate_stored_token_metadata(&token)?;
if !force_refresh && !token_needs_refresh(&token) {
if token.access_token.trim().is_empty() {
return Err(McpError::Config(format!(
"not authenticated; run magi-code mcp login {}",
self.server_name
)));
}
return Ok(token.access_token);
}
let refreshed = self.refresh_stored_token(&token)?;
if write_token_if_unchanged(&self.mc_home, &self.server_name, &token, &refreshed)? {
return Ok(refreshed.access_token);
}
}
}
fn validate_stored_token_metadata(&self, token: &StoredToken) -> McpResult<()> {
if !validate_token_url(token, &self.server_url) {
return Err(self.relogin_error("server URL changed since last login"));
}
if let Some(endpoint) = token
.token_endpoint
.as_deref()
.filter(|value| !value.is_empty())
{
validate_oauth_endpoint("stored token_endpoint", endpoint)?;
}
if token.resource.is_none()
&& token.authorization_server.is_none()
&& token.issuer.is_none()
{
return Ok(());
}
let protected = discover_protected_resource(&self.server_url, &self.client)?;
let current_resource = protected
.as_ref()
.map(|metadata| metadata.resource.as_str())
.unwrap_or(&self.server_url);
if token
.resource
.as_deref()
.is_some_and(|stored| stored != current_resource)
{
return Err(self.relogin_error("OAuth resource changed since last login"));
}
let auth_server = select_authorization_server(
self.oauth.authorization_server.as_deref(),
protected.as_ref(),
)?;
if token
.authorization_server
.as_deref()
.is_some_and(|stored| stored != auth_server)
{
return Err(self.relogin_error("OAuth authorization server changed since last login"));
}
if token.issuer.is_some() {
let metadata = discover_authorization_server(&auth_server, &self.client)?;
if token.issuer.as_deref() != Some(metadata.issuer.as_str()) {
return Err(self.relogin_error("OAuth issuer changed since last login"));
}
}
Ok(())
}
fn relogin_error(&self, reason: &str) -> McpError {
McpError::Config(format!(
"{reason}; run magi-code mcp login {}",
self.server_name
))
}
fn refresh_stored_token(&self, stored: &StoredToken) -> McpResult<StoredToken> {
let refresh = stored
.refresh_token
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| {
McpError::Config(format!(
"token expired; run magi-code mcp login {}",
self.server_name
))
})?;
let token_endpoint = match stored.token_endpoint.as_deref() {
Some(endpoint) if !endpoint.is_empty() => endpoint.to_string(),
_ => discover_token_endpoint(&self.server_url, &self.oauth, &self.client)?,
};
validate_oauth_endpoint("token_endpoint", &token_endpoint)?;
let response = refresh_token(
&token_endpoint,
refresh,
&stored.client_id,
&self.server_url,
stored.client_secret.as_deref(),
&self.client,
)
.map_err(|_| {
McpError::Config(format!(
"refresh token expired or revoked; run magi-code mcp login {}",
self.server_name
))
})?;
let mut next = stored_token_from_response(
response,
&stored.client_id,
stored.client_secret.clone(),
&self.server_url,
stored.authorization_server.clone(),
stored.issuer.clone(),
Some(token_endpoint),
stored.resource.clone(),
)?;
if next.refresh_token.is_none() {
next.refresh_token = stored.refresh_token.clone();
}
Ok(next)
}
}
pub(super) fn token_needs_refresh(token: &StoredToken) -> bool {
token
.expires_at
.is_some_and(|expires| expires <= Utc::now().timestamp() + REFRESH_SKEW_SECONDS)
}
pub(crate) fn auth_status(
mc_home: &Path,
server_name: &str,
server_url: &str,
) -> McpResult<&'static str> {
match read_token(mc_home, server_name)? {
None => Ok("not authenticated"),
Some(token) if !validate_token_url(&token, server_url) => Ok("invalid (needs login)"),
Some(token) if token_needs_refresh(&token) && token.refresh_token.is_some() => {
Ok("expired (refreshable)")
}
Some(token) if token_needs_refresh(&token) => Ok("expired (needs login)"),
Some(_) => Ok("authenticated"),
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn exchange_code(
token_endpoint: &str,
code: &str,
redirect_uri: &str,
client_id: &str,
code_verifier: &str,
resource: &str,
client_secret: Option<&str>,
client: &reqwest::blocking::Client,
) -> McpResult<TokenResponse> {
let mut form = vec![
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", redirect_uri),
("client_id", client_id),
("code_verifier", code_verifier),
("resource", resource),
];
if let Some(secret) = client_secret.filter(|value| !value.is_empty()) {
form.push(("client_secret", secret));
}
post_token_form(token_endpoint, &form, client)
}
pub(super) fn refresh_token(
token_endpoint: &str,
refresh_token_value: &str,
client_id: &str,
resource: &str,
client_secret: Option<&str>,
client: &reqwest::blocking::Client,
) -> McpResult<TokenResponse> {
let mut form = vec![
("grant_type", "refresh_token"),
("refresh_token", refresh_token_value),
("client_id", client_id),
("resource", resource),
];
if let Some(secret) = client_secret.filter(|value| !value.is_empty()) {
form.push(("client_secret", secret));
}
post_token_form(token_endpoint, &form, client)
}
fn post_token_form(
token_endpoint: &str,
form: &[(&str, &str)],
client: &reqwest::blocking::Client,
) -> McpResult<TokenResponse> {
validate_oauth_endpoint("token_endpoint", token_endpoint)?;
let refresh = form
.iter()
.any(|(key, value)| *key == "grant_type" && *value == "refresh_token");
let response = client.post(token_endpoint).form(form).send().map_err(|_| {
McpError::Transport(format!(
"network error during OAuth token request to {}",
sanitize_url(token_endpoint)
))
})?;
let status = response.status();
if !status.is_success() {
let body = bounded_response_text(response, 512);
return Err(McpError::Transport(token_error_message(
status, &body, refresh,
)));
}
let text = read_oauth_success_text(response, "MCP OAuth token")?;
let token: TokenResponse = serde_json::from_str(&text).map_err(|_| McpError::Protocol {
code: -32700,
message: "MCP OAuth token response is malformed JSON".to_string(),
})?;
validate_token_response(&token)?;
Ok(token)
}
fn bounded_response_text(mut response: reqwest::blocking::Response, max: usize) -> String {
let mut bytes = Vec::new();
let _ = response
.by_ref()
.take(max as u64 + 1)
.read_to_end(&mut bytes);
bytes.truncate(max);
String::from_utf8_lossy(&bytes).into_owned()
}
pub(super) fn read_oauth_success_text(
response: reqwest::blocking::Response,
response_label: &str,
) -> McpResult<String> {
read_bounded_response_text(response, DEFAULT_BOUNDED_BODY_MAX_BYTES).map_err(|error| {
let error = error.to_string();
if error.contains("response exceeded") {
McpError::Transport(format!(
"{response_label} response exceeded {DEFAULT_BOUNDED_BODY_MAX_BYTES} bytes"
))
} else {
McpError::Transport(format!("{response_label} response read failed: {error}"))
}
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn stored_token_from_response(
response: TokenResponse,
client_id: &str,
client_secret: Option<String>,
server_url: &str,
authorization_server: Option<String>,
issuer: Option<String>,
token_endpoint: Option<String>,
resource: Option<String>,
) -> McpResult<StoredToken> {
validate_token_response(&response)?;
let now = Utc::now().timestamp();
let scopes = response
.scope
.as_deref()
.unwrap_or("")
.split_whitespace()
.map(ToString::to_string)
.collect::<Vec<_>>();
let expires_at = response
.expires_in
.map(|seconds| {
let seconds = i64::try_from(seconds).map_err(|_| McpError::Protocol {
code: -32602,
message: "MCP OAuth token response expires_in is too large".to_string(),
})?;
now.checked_add(seconds).ok_or_else(|| McpError::Protocol {
code: -32602,
message: "MCP OAuth token response expires_in is too large".to_string(),
})
})
.transpose()?;
Ok(StoredToken {
client_id: client_id.to_string(),
access_token: response.access_token,
refresh_token: response.refresh_token.filter(|value| !value.is_empty()),
expires_at,
granted_scopes: scopes,
client_secret,
authorization_server,
issuer,
token_endpoint,
resource,
server_url: server_url.to_string(),
token_received_at: now,
})
}
fn validate_token_response(response: &TokenResponse) -> McpResult<()> {
if response.access_token.trim().is_empty() {
return Err(McpError::Protocol {
code: -32602,
message: "MCP OAuth token response missing access_token".to_string(),
});
}
if !response.token_type.is_empty() && !response.token_type.eq_ignore_ascii_case("bearer") {
return Err(McpError::Protocol {
code: -32602,
message: "MCP OAuth token response token_type is not bearer".to_string(),
});
}
Ok(())
}
fn discover_token_endpoint(
server_url: &str,
oauth: &McpOAuthConfig,
client: &reqwest::blocking::Client,
) -> McpResult<String> {
let protected = discover_protected_resource(server_url, client)?;
let auth_server =
select_authorization_server(oauth.authorization_server.as_deref(), protected.as_ref())?;
Ok(discover_authorization_server(&auth_server, client)?.token_endpoint)
}