use crate::net::http::{self, Url};
use crate::sec::secret;
use serde::Deserialize;
use std::sync::Mutex;
use std::time::{Duration, Instant};
const REFRESH_SKEW: Duration = Duration::from_secs(30);
const DEFAULT_TTL: Duration = Duration::from_secs(300);
#[derive(Debug, Clone)]
pub struct OAuthConfig {
pub token_url: String,
pub client_id: String,
pub client_secret: String,
pub scope: Option<String>,
}
struct Cached {
access_token: String,
good_until: Instant,
}
pub struct OAuthClient {
config: OAuthConfig,
cached: Mutex<Option<Cached>>,
timeout: Duration,
}
impl OAuthClient {
pub fn new(config: OAuthConfig, timeout: Duration) -> OAuthClient {
OAuthClient {
config,
cached: Mutex::new(None),
timeout,
}
}
pub fn bearer(&self) -> Result<String, String> {
let now = Instant::now();
{
let guard = self.cached.lock().unwrap_or_else(|e| e.into_inner());
if let Some(c) = guard.as_ref()
&& now < c.good_until
{
return Ok(c.access_token.clone());
}
}
let fresh = self.fetch()?;
let token = fresh.access_token.clone();
*self.cached.lock().unwrap_or_else(|e| e.into_inner()) = Some(fresh);
Ok(token)
}
fn fetch(&self) -> Result<Cached, String> {
let env = |k: &str| std::env::var(k).ok();
let client_secret = secret::resolve(&self.config.client_secret, &env)?;
let mut form = String::new();
form.push_str("grant_type=client_credentials");
form.push_str("&client_id=");
form.push_str(&form_encode(&self.config.client_id));
form.push_str("&client_secret=");
form.push_str(&form_encode(&client_secret));
if let Some(scope) = &self.config.scope {
form.push_str("&scope=");
form.push_str(&form_encode(scope));
}
let body = self.post_form(&self.config.token_url, form.as_bytes())?;
let parsed: TokenResponse =
serde_json::from_slice(&body).map_err(|e| format!("oauth: bad token response: {e}"))?;
let ttl = parsed
.expires_in
.map(Duration::from_secs)
.unwrap_or(DEFAULT_TTL);
let good_for = ttl.saturating_sub(REFRESH_SKEW).max(Duration::from_secs(1));
Ok(Cached {
access_token: parsed.access_token,
good_until: Instant::now() + good_for,
})
}
fn post_form(&self, url: &str, form: &[u8]) -> Result<Vec<u8>, String> {
let url = Url::parse(url).map_err(|e| format!("oauth: token_url: {e}"))?;
let mut stream = connect(&url, self.timeout)?;
let resp = http::send(
stream.as_mut(),
&url.host_header(),
"POST",
&url.path,
&[("Content-Type", "application/x-www-form-urlencoded")],
form,
)
.map_err(|e| format!("oauth: token request failed: {e}"))?;
if !resp.is_success() {
return Err(format!(
"oauth: token endpoint returned HTTP {}",
resp.status
));
}
Ok(resp.body)
}
}
pub struct OAuthBearerSigner {
client: OAuthClient,
}
impl OAuthBearerSigner {
pub fn new(spec: crate::config::McpOauthSpec, timeout: Duration) -> OAuthBearerSigner {
OAuthBearerSigner {
client: OAuthClient::new(
OAuthConfig {
token_url: spec.token_url,
client_id: spec.client_id,
client_secret: spec.client_secret,
scope: spec.scope,
},
timeout,
),
}
}
}
impl ::mcp::http::RequestSigner for OAuthBearerSigner {
fn sign(
&self,
_method: &str,
_authority: &str,
_path: &str,
_body: &[u8],
) -> Vec<(String, String)> {
match self.client.bearer() {
Ok(tok) => vec![("Authorization".to_string(), format!("Bearer {tok}"))],
Err(_) => Vec::new(),
}
}
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
#[serde(default)]
expires_in: Option<u64>,
}
fn connect(url: &Url, timeout: Duration) -> Result<Box<dyn http::Stream>, String> {
let tcp = http::connect_tcp(&url.host, url.port, timeout)
.map_err(|e| format!("oauth: connect {}: {e}", url.host))?;
if url.is_tls() {
#[cfg(feature = "tls")]
{
let s = crate::net::tls::connect(tcp, &url.host, None)
.map_err(|e| format!("oauth: tls {}: {e}", url.host))?;
Ok(Box::new(s))
}
#[cfg(not(feature = "tls"))]
{
Err("oauth: https token_url requires building with --features tls".to_string())
}
} else {
Ok(Box::new(tcp))
}
}
fn form_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn form_encode_escapes_reserved_and_keeps_unreserved() {
assert_eq!(form_encode("abcXYZ0-9._~"), "abcXYZ0-9._~");
assert_eq!(form_encode("a b"), "a%20b");
assert_eq!(form_encode("s3cr3t/+=&"), "s3cr3t%2F%2B%3D%26");
}
#[test]
fn token_response_parses_minimal_and_full() {
let full: TokenResponse = serde_json::from_str(
r#"{"access_token":"tok","token_type":"Bearer","expires_in":3600}"#,
)
.unwrap();
assert_eq!(full.access_token, "tok");
assert_eq!(full.expires_in, Some(3600));
let minimal: TokenResponse = serde_json::from_str(r#"{"access_token":"tok2"}"#).unwrap();
assert_eq!(minimal.access_token, "tok2");
assert_eq!(minimal.expires_in, None);
}
}