chore: add oauth2 support (#130)

1.add oauth2 server and client example
2.add oauth2 impl in sse
3.add auth doc 
Signed-off-by: jokemanfire <hu.dingyang@zte.com.cn>
This commit is contained in:
jokemanfire 2025-04-29 17:52:59 +08:00 • committed by GitHub
commit afb8a905e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2480 additions and 7 deletions

View file

@ -179,6 +179,10 @@ For many cases you need to manage several service in a collection, you can call
let service = service.into_dyn();
```
### OAuth Support
See [docs/OAUTH_SUPPORT.md](docs/OAUTH_SUPPORT.md) for details.
### Examples
See [examples](examples/README.md)

View file

@ -23,6 +23,10 @@ tracing = { version = "0.1" }
tokio-util = { version = "0.7" }
pin-project-lite = "0.2"
paste = { version = "1", optional = true }
# oauth2 support
oauth2 = { version = "5.0", optional = true }
# for auto generate schema
schemars = { version = "0.8", optional = true }
@ -70,6 +74,8 @@ transport-sse-server = [
]
# transport-ws = ["transport-io", "dep:tokio-tungstenite"]
tower = ["dep:tower-service"]
auth = ["dep:oauth2", "dep:reqwest", "dep:url"]
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
schemars = { version = "0.8" }

View file

@ -56,6 +56,11 @@ pub mod sse;
#[cfg(feature = "transport-sse")]
pub use sse::SseTransport;
#[cfg(all(feature = "transport-sse", feature = "auth"))]
pub mod sse_auth;
#[cfg(all(feature = "transport-sse", feature = "auth"))]
pub use sse_auth::{AuthorizedSseClient, create_authorized_transport};
// #[cfg(feature = "tower")]
// pub mod tower;
@ -64,6 +69,11 @@ pub mod sse_server;
#[cfg(feature = "transport-sse-server")]
pub use sse_server::SseServer;
#[cfg(feature = "auth")]
pub mod auth;
#[cfg(feature = "auth")]
pub use auth::{AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient};
// #[cfg(feature = "transport-ws")]
// pub mod ws;

View file

@ -0,0 +1,718 @@
use std::{
sync::Arc,
time::{Duration, Instant},
};
use oauth2::{
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EmptyExtraTokenFields,
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, Scope, StandardTokenResponse,
TokenResponse, TokenUrl,
basic::{BasicClient, BasicTokenType},
};
use reqwest::{Client as HttpClient, IntoUrl, StatusCode, Url, header::AUTHORIZATION};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use tokio::sync::RwLock;
use tracing::{debug, error};
/// Auth error
#[derive(Debug, Error)]
pub enum AuthError {
#[error("OAuth authorization required")]
AuthorizationRequired,
#[error("OAuth authorization failed: {0}")]
AuthorizationFailed(String),
#[error("OAuth token exchange failed: {0}")]
TokenExchangeFailed(String),
#[error("OAuth token refresh failed: {0}")]
TokenRefreshFailed(String),
#[error("HTTP error: {0}")]
HttpError(#[from] reqwest::Error),
#[error("OAuth error: {0}")]
OAuthError(String),
#[error("Metadata error: {0}")]
MetadataError(String),
#[error("URL parse error: {0}")]
UrlError(#[from] url::ParseError),
#[error("No authorization support detected")]
NoAuthorizationSupport,
#[error("Internal error: {0}")]
InternalError(String),
#[error("Invalid token type: {0}")]
InvalidTokenType(String),
#[error("Token expired")]
TokenExpired,
#[error("Invalid scope: {0}")]
InvalidScope(String),
#[error("Registration failed: {0}")]
RegistrationFailed(String),
}
/// oauth2 metadata
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AuthorizationMetadata {
pub authorization_endpoint: String,
pub token_endpoint: String,
pub registration_endpoint: String,
pub issuer: Option<String>,
pub jwks_uri: Option<String>,
pub scopes_supported: Option<Vec<String>>,
}
/// oauth2 client config
#[derive(Debug, Clone)]
pub struct OAuthClientConfig {
pub client_id: String,
pub client_secret: Option<String>,
pub scopes: Vec<String>,
pub redirect_uri: String,
}
// add type aliases for oauth2 types
type OAuthErrorResponse = oauth2::StandardErrorResponse<oauth2::basic::BasicErrorResponseType>;
type OAuthTokenResponse = StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>;
type OAuthTokenIntrospection =
oauth2::StandardTokenIntrospectionResponse<EmptyExtraTokenFields, BasicTokenType>;
type OAuthRevocableToken = oauth2::StandardRevocableToken;
type OAuthRevocationError = oauth2::StandardErrorResponse<oauth2::RevocationErrorResponseType>;
type OAuthClient = oauth2::Client<
OAuthErrorResponse,
OAuthTokenResponse,
OAuthTokenIntrospection,
OAuthRevocableToken,
OAuthRevocationError,
oauth2::EndpointSet,
oauth2::EndpointNotSet,
oauth2::EndpointNotSet,
oauth2::EndpointNotSet,
oauth2::EndpointSet,
>;
/// oauth2 auth manager
pub struct AuthorizationManager {
http_client: HttpClient,
metadata: Option<AuthorizationMetadata>,
oauth_client: Option<OAuthClient>,
credentials: RwLock<Option<OAuthTokenResponse>>,
pkce_verifier: RwLock<Option<PkceCodeVerifier>>,
expires_at: RwLock<Option<Instant>>,
base_url: Url,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientRegistrationRequest {
pub client_name: String,
pub redirect_uris: Vec<String>,
pub grant_types: Vec<String>,
pub token_endpoint_auth_method: String,
pub response_types: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientRegistrationResponse {
pub client_id: String,
pub client_secret: String,
pub client_name: String,
pub redirect_uris: Vec<String>,
}
impl AuthorizationManager {
/// create new auth manager with base url
pub async fn new<U: IntoUrl>(base_url: U) -> Result<Self, AuthError> {
let base_url = base_url.into_url()?;
let http_client = HttpClient::builder()
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| AuthError::InternalError(e.to_string()))?;
let manager = Self {
http_client,
metadata: None,
oauth_client: None,
credentials: RwLock::new(None),
pkce_verifier: RwLock::new(None),
expires_at: RwLock::new(None),
base_url,
};
Ok(manager)
}
pub fn with_client(&mut self, http_client: HttpClient) -> Result<(), AuthError> {
self.http_client = http_client;
Ok(())
}
/// discover oauth2 metadata
pub async fn discover_metadata(&self) -> Result<AuthorizationMetadata, AuthError> {
// according to the specification, the metadata should be located at "/.well-known/oauth-authorization-server"
let mut discovery_url = self.base_url.clone();
discovery_url.set_path("/.well-known/oauth-authorization-server");
debug!("discovery url: {:?}", discovery_url);
let response = self
.http_client
.get(discovery_url)
.header("MCP-Protocol-Version", "2024-11-05")
.send()
.await?;
if response.status() == StatusCode::OK {
let metadata = response
.json::<AuthorizationMetadata>()
.await
.map_err(|e| {
AuthError::MetadataError(format!("Failed to parse metadata: {}", e))
})?;
debug!("metadata: {:?}", metadata);
Ok(metadata)
} else {
// fallback to default endpoints
let mut auth_base = self.base_url.clone();
// discard the path part, only keep scheme, host, port
auth_base.set_path("");
Ok(AuthorizationMetadata {
authorization_endpoint: format!("{}/authorize", auth_base),
token_endpoint: format!("{}/token", auth_base),
registration_endpoint: format!("{}/register", auth_base),
issuer: None,
jwks_uri: None,
scopes_supported: None,
})
}
}
/// configure oauth2 client with client credentials
pub fn configure_client(&mut self, config: OAuthClientConfig) -> Result<(), AuthError> {
if self.metadata.is_none() {
return Err(AuthError::NoAuthorizationSupport);
}
let metadata = self.metadata.as_ref().unwrap();
let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone())
.map_err(|e| AuthError::OAuthError(format!("Invalid authorization URL: {}", e)))?;
let token_url = TokenUrl::new(metadata.token_endpoint.clone())
.map_err(|e| AuthError::OAuthError(format!("Invalid token URL: {}", e)))?;
// debug!("token url: {:?}", token_url);
let client_id = ClientId::new(config.client_id);
let redirect_url = RedirectUrl::new(config.redirect_uri.clone())
.map_err(|e| AuthError::OAuthError(format!("Invalid re URL: {}", e)))?;
debug!("client_id: {:?}", client_id);
let mut client_builder = BasicClient::new(client_id.clone())
.set_auth_uri(auth_url)
.set_token_uri(token_url)
.set_redirect_uri(redirect_url);
if let Some(secret) = config.client_secret {
client_builder = client_builder.set_client_secret(ClientSecret::new(secret));
}
self.oauth_client = Some(client_builder);
Ok(())
}
/// dynamic register oauth2 client
pub async fn register_client(
&mut self,
name: &str,
redirect_uri: &str,
) -> Result<OAuthClientConfig, AuthError> {
if self.metadata.is_none() {
error!("No authorization support detected");
return Err(AuthError::NoAuthorizationSupport);
}
let metadata = self.metadata.as_ref().unwrap();
let registration_url = metadata.registration_endpoint.clone();
debug!("registration url: {:?}", registration_url);
// prepare registration request
let registration_request = ClientRegistrationRequest {
client_name: name.to_string(),
redirect_uris: vec![redirect_uri.to_string()],
grant_types: vec![
"authorization_code".to_string(),
"refresh_token".to_string(),
],
token_endpoint_auth_method: "none".to_string(), // public client
response_types: vec!["code".to_string()],
};
debug!("registration request: {:?}", registration_request);
let response = match self
.http_client
.post(registration_url)
.json(&registration_request)
.send()
.await
{
Ok(response) => response,
Err(e) => {
error!("Registration request failed: {}", e);
return Err(AuthError::RegistrationFailed(format!(
"HTTP request error: {}",
e
)));
}
};
if !response.status().is_success() {
let status = response.status();
let error_text = match response.text().await {
Ok(text) => text,
Err(_) => "cannot get error details".to_string(),
};
error!("Registration failed: HTTP {} - {}", status, error_text);
return Err(AuthError::RegistrationFailed(format!(
"HTTP {}: {}",
status, error_text
)));
}
debug!("registration response: {:?}", response);
let reg_response = match response.json::<ClientRegistrationResponse>().await {
Ok(response) => response,
Err(e) => {
error!("Failed to parse registration response: {}", e);
return Err(AuthError::RegistrationFailed(format!(
"analyze response error: {}",
e
)));
}
};
let config = OAuthClientConfig {
client_id: reg_response.client_id,
client_secret: Some(reg_response.client_secret),
redirect_uri: redirect_uri.to_string(),
scopes: vec![],
};
self.configure_client(config.clone())?;
Ok(config)
}
/// generate authorization url
pub async fn get_authorization_url(&self, scopes: &[&str]) -> Result<String, AuthError> {
let oauth_client = self
.oauth_client
.as_ref()
.ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?;
// generate pkce challenge
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
// build authorization request
let mut auth_request = oauth_client
.authorize_url(CsrfToken::new_random)
.set_pkce_challenge(pkce_challenge);
// add request scopes
for scope in scopes {
auth_request = auth_request.add_scope(Scope::new(scope.to_string()));
}
let (auth_url, _csrf_token) = auth_request.url();
// store pkce verifier for later use
*self.pkce_verifier.write().await = Some(pkce_verifier);
debug!("set pkce verifier: {:?}", self.pkce_verifier.read().await);
Ok(auth_url.to_string())
}
/// exchange authorization code for access token
pub async fn exchange_code_for_token(
&self,
code: &str,
) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, AuthError> {
debug!("start exchange code for token: {:?}", code);
let oauth_client = self
.oauth_client
.as_ref()
.ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?;
let pkce_verifier = self
.pkce_verifier
.write()
.await
.take()
.ok_or_else(|| AuthError::InternalError("PKCE verifier not found".to_string()))?;
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| AuthError::InternalError(e.to_string()))?;
debug!("client_id: {:?}", oauth_client.client_id());
// exchange token
let token_result = oauth_client
.exchange_code(AuthorizationCode::new(code.to_string()))
.add_extra_param("client_id", oauth_client.client_id().to_string())
.set_pkce_verifier(pkce_verifier)
.request_async(&http_client)
.await
.map_err(|e| AuthError::TokenExchangeFailed(e.to_string()))?;
// get expires_in from token response
let expires_in = token_result.expires_in();
if let Some(expires_in) = expires_in {
let expires_at = Instant::now() + expires_in;
*self.expires_at.write().await = Some(expires_at);
}
debug!("exchange token result: {:?}", token_result);
// store credentials
*self.credentials.write().await = Some(token_result.clone());
Ok(token_result)
}
/// get access token, if expired, refresh it automatically
pub async fn get_access_token(&self) -> Result<String, AuthError> {
let credentials = self.credentials.read().await;
if let Some(creds) = credentials.as_ref() {
// check if the token is expire
if let Some(expires_at) = *self.expires_at.read().await {
if expires_at < Instant::now() {
// token expired, try to refresh , release the lock
drop(credentials);
let new_creds = self.refresh_token().await?;
return Ok(new_creds.access_token().secret().to_string());
}
}
Ok(creds.access_token().secret().to_string())
} else {
Err(AuthError::AuthorizationRequired)
}
}
/// refresh access token
pub async fn refresh_token(
&self,
) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, AuthError> {
let oauth_client = self
.oauth_client
.as_ref()
.ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?;
let current_credentials = self
.credentials
.read()
.await
.clone()
.ok_or_else(|| AuthError::AuthorizationRequired)?;
let refresh_token = current_credentials.refresh_token().ok_or_else(|| {
AuthError::TokenRefreshFailed("No refresh token available".to_string())
})?;
debug!("refresh token: {:?}", refresh_token);
// refresh token
let token_result = oauth_client
.exchange_refresh_token(&RefreshToken::new(refresh_token.secret().to_string()))
.request_async(&self.http_client)
.await
.map_err(|e| AuthError::TokenRefreshFailed(e.to_string()))?;
// store new credentials
*self.credentials.write().await = Some(token_result.clone());
// get expires_in from token response
let expires_in = token_result.expires_in();
if let Some(expires_in) = expires_in {
let expires_at = Instant::now() + expires_in;
*self.expires_at.write().await = Some(expires_at);
}
Ok(token_result)
}
/// prepare request, add authorization header
pub async fn prepare_request(
&self,
request: reqwest::RequestBuilder,
) -> Result<reqwest::RequestBuilder, AuthError> {
let token = self.get_access_token().await?;
Ok(request.header(AUTHORIZATION, format!("Bearer {}", token)))
}
/// handle response, check if need to re-authorize
pub async fn handle_response(
&self,
response: reqwest::Response,
) -> Result<reqwest::Response, AuthError> {
if response.status() == StatusCode::UNAUTHORIZED {
// 401 Unauthorized, need to re-authorize
Err(AuthError::AuthorizationRequired)
} else {
Ok(response)
}
}
}
/// oauth2 authorization session, for guiding user to complete the authorization process
pub struct AuthorizationSession {
pub auth_manager: AuthorizationManager,
pub auth_url: String,
pub redirect_uri: String,
}
impl AuthorizationSession {
/// create new authorization session
pub async fn new(
mut auth_manager: AuthorizationManager,
scopes: &[&str],
redirect_uri: &str,
) -> Result<Self, AuthError> {
// set redirect uri
let config = OAuthClientConfig {
client_id: "mcp-client".to_string(), // temporary id, will be updated by dynamic registration
client_secret: None,
scopes: scopes.iter().map(|s| s.to_string()).collect(),
redirect_uri: redirect_uri.to_string(),
};
// try to dynamic register client
let config = match auth_manager
.register_client("MCP Client", redirect_uri)
.await
{
Ok(config) => config,
Err(e) => {
eprintln!("Dynamic registration failed: {}", e);
// fallback to default config
config
}
};
// reset client config
auth_manager.configure_client(config)?;
let auth_url = auth_manager.get_authorization_url(scopes).await?;
Ok(Self {
auth_manager,
auth_url,
redirect_uri: redirect_uri.to_string(),
})
}
/// get authorization url
pub fn get_authorization_url(&self) -> &str {
&self.auth_url
}
/// handle authorization code callback
pub async fn handle_callback(
&self,
code: &str,
) -> Result<StandardTokenResponse<EmptyExtraTokenFields, BasicTokenType>, AuthError> {
self.auth_manager.exchange_code_for_token(code).await
}
}
/// http client extension, automatically add authorization header
pub struct AuthorizedHttpClient {
auth_manager: Arc<AuthorizationManager>,
inner_client: HttpClient,
}
impl AuthorizedHttpClient {
/// create new authorized http client
pub fn new(auth_manager: Arc<AuthorizationManager>, client: Option<HttpClient>) -> Self {
let inner_client = client.unwrap_or_default();
Self {
auth_manager,
inner_client,
}
}
/// send authorized request
pub async fn request<U: IntoUrl>(
&self,
method: reqwest::Method,
url: U,
) -> Result<reqwest::RequestBuilder, AuthError> {
let request = self.inner_client.request(method, url);
self.auth_manager.prepare_request(request).await
}
/// send get request
pub async fn get<U: IntoUrl>(&self, url: U) -> Result<reqwest::Response, AuthError> {
let request = self.request(reqwest::Method::GET, url).await?;
let response = request.send().await?;
self.auth_manager.handle_response(response).await
}
/// send post request
pub async fn post<U: IntoUrl>(&self, url: U) -> Result<reqwest::RequestBuilder, AuthError> {
self.request(reqwest::Method::POST, url).await
}
}
/// OAuth state machine
/// Use the OAuthState to manage the OAuth client is more recommend
/// But also you can use the AuthorizationManager,AuthorizationSession,AuthorizedHttpClient directly
pub enum OAuthState {
/// the AuthorizationManager
Unauthorized(AuthorizationManager),
/// the AuthorizationSession
Session(AuthorizationSession),
/// the authd AuthorizationManager
Authorized(AuthorizationManager),
/// the authd http client
AuthorizedHttpClient(AuthorizedHttpClient),
}
impl OAuthState {
/// Create new OAuth state machine
pub async fn new<U: IntoUrl>(
base_url: U,
client: Option<HttpClient>,
) -> Result<Self, AuthError> {
let mut manager = AuthorizationManager::new(base_url).await?;
if let Some(client) = client {
manager.with_client(client)?;
}
Ok(OAuthState::Unauthorized(manager))
}
/// start authorization
pub async fn start_authorization(
&mut self,
scopes: &[&str],
redirect_uri: &str,
) -> Result<(), AuthError> {
if let OAuthState::Unauthorized(mut manager) = std::mem::replace(
self,
OAuthState::Unauthorized(AuthorizationManager::new("http://localhost").await?),
) {
debug!("start discovery");
let metadata = manager.discover_metadata().await?;
manager.metadata = Some(metadata);
debug!("start session");
let session = AuthorizationSession::new(manager, scopes, redirect_uri).await?;
*self = OAuthState::Session(session);
Ok(())
} else {
Err(AuthError::InternalError(
"Already in session state".to_string(),
))
}
}
/// complete authorization
pub async fn complete_authorization(&mut self) -> Result<(), AuthError> {
if let OAuthState::Session(session) = std::mem::replace(
self,
OAuthState::Unauthorized(AuthorizationManager::new("http://localhost").await?),
) {
*self = OAuthState::Authorized(session.auth_manager);
Ok(())
} else {
Err(AuthError::InternalError("Not in session state".to_string()))
}
}
/// covert to authorized http client
pub async fn to_authorized_http_client(&mut self) -> Result<(), AuthError> {
if let OAuthState::Authorized(manager) = std::mem::replace(
self,
OAuthState::Authorized(AuthorizationManager::new("http://localhost").await?),
) {
*self = OAuthState::AuthorizedHttpClient(AuthorizedHttpClient::new(
Arc::new(manager),
None,
));
Ok(())
} else {
Err(AuthError::InternalError(
"Not in authorized state".to_string(),
))
}
}
/// get current authorization url
pub async fn get_authorization_url(&self) -> Result<String, AuthError> {
match self {
OAuthState::Session(session) => Ok(session.get_authorization_url().to_string()),
OAuthState::Unauthorized(_) => {
Err(AuthError::InternalError("Not in session state".to_string()))
}
OAuthState::Authorized(_) => {
Err(AuthError::InternalError("Already authorized".to_string()))
}
OAuthState::AuthorizedHttpClient(_) => {
Err(AuthError::InternalError("Already authorized".to_string()))
}
}
}
/// handle authorization callback
pub async fn handle_callback(&mut self, code: &str) -> Result<(), AuthError> {
match self {
OAuthState::Session(session) => {
session.handle_callback(code).await?;
self.complete_authorization().await
}
OAuthState::Unauthorized(_) => {
Err(AuthError::InternalError("Not in session state".to_string()))
}
OAuthState::Authorized(_) => {
Err(AuthError::InternalError("Already authorized".to_string()))
}
OAuthState::AuthorizedHttpClient(_) => {
Err(AuthError::InternalError("Already authorized".to_string()))
}
}
}
/// get access token
pub async fn get_access_token(&self) -> Result<String, AuthError> {
match self {
OAuthState::Unauthorized(manager) => manager.get_access_token().await,
OAuthState::Session(_) => {
Err(AuthError::InternalError("Not in manager state".to_string()))
}
OAuthState::Authorized(_) => {
Err(AuthError::InternalError("Already authorized".to_string()))
}
OAuthState::AuthorizedHttpClient(_) => {
Err(AuthError::InternalError("Already authorized".to_string()))
}
}
}
/// refresh access token
pub async fn refresh_token(&self) -> Result<(), AuthError> {
match self {
OAuthState::Unauthorized(_) => {
Err(AuthError::InternalError("Not in manager state".to_string()))
}
OAuthState::Session(_) => {
Err(AuthError::InternalError("Not in manager state".to_string()))
}
OAuthState::Authorized(manager) => {
manager.refresh_token().await?;
Ok(())
}
OAuthState::AuthorizedHttpClient(_) => {
Err(AuthError::InternalError("Already authorized".to_string()))
}
}
}
}

View file

@ -0,0 +1,183 @@
use std::sync::Arc;
use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
use reqwest::{
Client as HttpClient, IntoUrl, Url,
header::{ACCEPT, AUTHORIZATION},
};
use sse_stream::{Error as SseError, Sse, SseStream};
use tokio::sync::Mutex;
use super::{
auth::{AuthError, AuthorizationManager, OAuthState},
sse::{SseClient, SseTransport, SseTransportError, SseTransportRetryConfig},
};
use crate::model::ClientJsonRpcMessage;
// SSE MIME type
const MIME_TYPE: &str = "text/event-stream";
const HEADER_LAST_EVENT_ID: &str = "Last-Event-ID";
/// sse client with oauth2 authorization
#[derive(Clone)]
pub struct AuthorizedSseClient {
http_client: HttpClient,
sse_url: Url,
auth_manager: Arc<Mutex<AuthorizationManager>>,
_retry_config: Option<SseTransportRetryConfig>, // TODO retry config may be used by authorized transport for token
}
impl AuthorizedSseClient {
/// create new authorized sse client
pub fn new<U>(
url: U,
auth_manager: Arc<Mutex<AuthorizationManager>>,
retry_config: Option<SseTransportRetryConfig>,
) -> Result<Self, SseTransportError<reqwest::Error>>
where
U: IntoUrl,
{
let url = url.into_url().map_err(SseTransportError::from)?;
Ok(Self {
http_client: HttpClient::default(),
sse_url: url,
auth_manager,
_retry_config: retry_config,
})
}
/// create authorized sse client with custom http client
pub async fn new_with_client<U>(
url: U,
client: HttpClient,
auth_manager: Arc<Mutex<AuthorizationManager>>,
retry_config: Option<SseTransportRetryConfig>,
) -> Result<Self, SseTransportError<reqwest::Error>>
where
U: IntoUrl,
{
let url = url.into_url().map_err(SseTransportError::from)?;
Ok(Self {
http_client: client,
sse_url: url,
auth_manager,
_retry_config: retry_config,
})
}
}
impl SseClient<reqwest::Error> for AuthorizedSseClient {
fn connect(
&self,
last_event_id: Option<String>,
) -> BoxFuture<
'static,
Result<BoxStream<'static, Result<Sse, SseError>>, SseTransportError<reqwest::Error>>,
> {
let client = self.http_client.clone();
let sse_url = self.sse_url.as_ref().to_string();
let last_event_id = last_event_id.clone();
let auth_manager = self.auth_manager.clone();
let fut = async move {
// get access token
let token = auth_manager.lock().await.get_access_token().await?;
// build request
let mut request_builder = client
.get(&sse_url)
.header(ACCEPT, MIME_TYPE)
.header(AUTHORIZATION, format!("Bearer {}", token));
if let Some(last_event_id) = last_event_id {
request_builder = request_builder.header(HEADER_LAST_EVENT_ID, last_event_id);
}
let response = request_builder.send().await?;
let response = response.error_for_status()?;
match response.headers().get(reqwest::header::CONTENT_TYPE) {
Some(ct) => {
if !ct.as_bytes().starts_with(MIME_TYPE.as_bytes()) {
return Err(SseTransportError::UnexpectedContentType(Some(ct.clone())));
}
}
None => {
return Err(SseTransportError::UnexpectedContentType(None));
}
}
let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed();
Ok(event_stream)
};
Box::pin(fut)
}
fn post(
&self,
session_id: &str,
message: ClientJsonRpcMessage,
) -> BoxFuture<'static, Result<(), SseTransportError<reqwest::Error>>> {
let client = self.http_client.clone();
let sse_url = self.sse_url.clone();
let session_id = session_id.to_string();
let auth_manager = self.auth_manager.clone();
Box::pin(async move {
// get access token
let token = auth_manager
.lock()
.await
.get_access_token()
.await
.map_err(SseTransportError::<reqwest::Error>::from)?;
let uri = sse_url.join(&session_id).map_err(SseTransportError::from)?;
let request_builder = client
.post(uri.as_ref())
.header(AUTHORIZATION, format!("Bearer {}", token))
.json(&message);
request_builder
.send()
.await
.and_then(|resp| resp.error_for_status())
.map_err(SseTransportError::from)
.map(drop)
})
}
}
impl From<AuthError> for SseTransportError<reqwest::Error> {
fn from(err: AuthError) -> Self {
SseTransportError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
err.to_string(),
))
}
}
/// create authorized sse transport
pub async fn create_authorized_transport<U>(
url: U,
auth_state: OAuthState,
retry_config: Option<SseTransportRetryConfig>,
) -> Result<SseTransport<AuthorizedSseClient, reqwest::Error>, SseTransportError<reqwest::Error>>
where
U: IntoUrl,
{
match auth_state {
OAuthState::Authorized(auth_manager) => {
let auth_manager = Arc::new(Mutex::new(auth_manager));
let client = AuthorizedSseClient::new(url, auth_manager, retry_config)?;
let mut transport = SseTransport::start_with_client(client).await?;
transport.retry_config = retry_config.unwrap_or_default();
Ok(transport)
}
_ => Err(SseTransportError::Io(std::io::Error::new(
std::io::ErrorKind::Other,
"Not authorized".to_string(),
))),
}
}

125
docs/OAUTH_SUPPORT.md Normal file
View file

@ -0,0 +1,125 @@
# Model Context Protocol OAuth Authorization
This document describes the OAuth 2.1 authorization implementation for Model Context Protocol (MCP), following the [MCP 2025-03-26 Authorization Specification](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/).
## Features
- Full support for OAuth 2.1 authorization flow
- PKCE support for enhanced security
- Authorization server metadata discovery
- Dynamic client registration
- Automatic token refresh
- Authorized SSE transport implementation
- Authorized HTTP Client implementation
## Usage Guide
### 1. Enable Features
Enable the auth feature in Cargo.toml:
```toml
[dependencies]
rmcp = { version = "0.1", features = ["auth", "transport-sse"] }
```
### 2. Use OAuthState
```rust ignore
// Initialize oauth state machine
let mut oauth_state = OAuthState::new(&server_url, None)
.await
.context("Failed to initialize oauth state machine")?;
oauth_state
.start_authorization(&["mcp", "profile", "email"], MCP_REDIRECT_URI)
.await
.context("Failed to start authorization")?;
```
### 3. Get authorization url and do callback
```rust ignore
// Get authorization URL and guide user to open it
let auth_url = oauth_state.get_authorization_url().await?;
println!("Please open the following URL in your browser for authorization:\n{}", auth_url);
// Handle callback - In real applications, this is typically done in a callback server
let auth_code = "Authorization code obtained from browser after user authorization";
let credentials = oauth_state.handle_callback(auth_code).await?;
println!("Authorization successful, access token: {}", credentials.access_token);
```
### 4. Use Authorized SSE Transport and create client
```rust ignore
let transport =
match create_authorized_transport(MCP_SSE_URL.to_string(), oauth_state, Some(retry_config))
.await
{
Ok(t) => t,
Err(e) => {
tracing::error!("Failed to create authorized transport: {}", e);
return Err(anyhow::anyhow!("Connection failed: {}", e));
}
};
// Create client and connect to MCP server
let client_service = ClientInfo::default();
let client = client_service.serve(transport).await?;
```
### 5. May you can use Authorized HTTP Client after authorized
```rust ignore
let client = oauth_state.to_authorized_http_client().await?;
```
## Complete Example
client: Please refer to `examples/clients/src/oauth_client.rs` for a complete usage example.
server: Please refer to `examples/servers/src/mcp_oauth_server.rs` for a complete usage example.
### Running the Example in server
```bash
# Run example
cargo run --example mcp_oauth_server
```
### Running the Example in client
```bash
# Run example
cargo run --example oauth-client
```
## Authorization Flow Description
1. **Metadata Discovery**: Client attempts to get authorization server metadata from `/.well-known/oauth-authorization-server`
2. **Client Registration**: If supported, client dynamically registers itself
3. **Authorization Request**: Build authorization URL with PKCE and guide user to access
4. **Authorization Code Exchange**: After user authorization, exchange authorization code for access token
5. **Token Usage**: Use access token for API calls
6. **Token Refresh**: Automatically use refresh token to get new access token when current one expires
## Security Considerations
- All tokens are securely stored in memory
- PKCE implementation prevents authorization code interception attacks
- Automatic token refresh support reduces user intervention
- Only accepts HTTPS connections or secure local callback URIs
## Troubleshooting
If you encounter authorization issues, check the following:
1. Ensure server supports OAuth 2.1 authorization
2. Verify callback URI matches server's allowed redirect URIs
3. Check network connection and firewall settings
4. Verify server supports metadata discovery or dynamic client registration
## References
- [MCP Authorization Specification](https://spec.modelcontextprotocol.io/specification/2025-03-26/basic/authorization/)
- [OAuth 2.1 Specification Draft](https://oauth.net/2.1/)
- [RFC 8414: OAuth 2.0 Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414)
- [RFC 7591: OAuth 2.0 Dynamic Client Registration Protocol](https://datatracker.ietf.org/doc/html/rfc7591)

View file

@ -11,7 +11,8 @@ rmcp = { path = "../../crates/rmcp", features = [
"client",
"transport-sse",
"transport-child-process",
"tower"
"tower",
"auth"
] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
@ -21,8 +22,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
rand = "0.8"
futures = "0.3"
anyhow = "1.0"
url = "2.4"
tower = "0.5"
axum = "0.8"
[[example]]
name = "clients_sse"
@ -40,3 +42,6 @@ path = "src/everything_stdio.rs"
name = "clients_collection"
path = "src/collection.rs"
[[example]]
name = "oauth_client"
path = "src/oauth_client.rs"

View file

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OAuth Success</title>
<style>
:root {
--primary-color: #4CAF50;
--background-color: #f8f9fa;
--text-color: #333;
--border-color: #e0e0e0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--background-color);
color: var(--text-color);
}
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
text-align: center;
max-width: 500px;
width: 90%;
margin: 1rem;
}
.icon {
font-size: 4rem;
color: var(--primary-color);
margin-bottom: 1rem;
animation: scaleIn 0.5s ease-out;
}
h1 {
color: var(--primary-color);
margin: 0 0 1rem 0;
font-size: 1.8rem;
}
p {
margin: 0;
line-height: 1.6;
color: #666;
}
.close-button {
margin-top: 1.5rem;
padding: 0.75rem 1.5rem;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.2s;
}
.close-button:hover {
background-color: #45a049;
}
@keyframes scaleIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
</style>
</head>
<body>
<div class="container">
<div class="icon">✓</div>
<h1>Authorization Success</h1>
<p>You have successfully authorized the MCP client. You can now close this window and return to the application.</p>
<button class="close-button" onclick="window.close()">Close Window</button>
</div>
</body>
</html>

View file

@ -0,0 +1,228 @@
use std::{net::SocketAddr, sync::Arc, time::Duration};
use anyhow::{Context, Result};
use axum::{
Router,
extract::{Query, State},
response::Html,
routing::get,
};
use rmcp::{
ServiceExt,
model::ClientInfo,
transport::{auth::OAuthState, create_authorized_transport, sse::SseTransportRetryConfig},
};
use serde::Deserialize;
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter},
sync::{Mutex, oneshot},
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
const MCP_SERVER_URL: &str = "http://localhost:3000/mcp";
const MCP_REDIRECT_URI: &str = "http://localhost:8080/callback";
const MCP_SSE_URL: &str = "http://localhost:3000/mcp/sse";
const CALLBACK_PORT: u16 = 8080;
const CALLBACK_HTML: &str = include_str!("callback.html");
#[derive(Clone)]
struct AppState {
code_receiver: Arc<Mutex<Option<oneshot::Sender<String>>>>,
}
#[derive(Debug, Deserialize)]
struct CallbackParams {
code: String,
#[allow(dead_code)]
state: Option<String>,
}
async fn callback_handler(
Query(params): Query<CallbackParams>,
State(state): State<AppState>,
) -> Html<String> {
tracing::info!("Received callback with code: {}", params.code);
// Send the code to the main thread
if let Some(sender) = state.code_receiver.lock().await.take() {
let _ = sender.send(params.code);
}
// Return success page
Html(CALLBACK_HTML.to_string())
}
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "debug".to_string().into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
// it is a http server for handling callback
// Create channel for receiving authorization code
let (code_sender, code_receiver) = oneshot::channel::<String>();
// Create app state
let app_state = AppState {
code_receiver: Arc::new(Mutex::new(Some(code_sender))),
};
// Start HTTP server for handling callbacks
let app = Router::new()
.route("/callback", get(callback_handler))
.with_state(app_state);
let addr = SocketAddr::from(([127, 0, 0, 1], CALLBACK_PORT));
tracing::info!("Starting callback server at: http://{}", addr);
// Start server in a separate task
tokio::spawn(async move {
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
let result = axum::serve(listener, app).await;
if let Err(e) = result {
tracing::error!("Callback server error: {}", e);
}
});
// Get server URL
let server_url = MCP_SERVER_URL.to_string();
tracing::info!("Using MCP server URL: {}", server_url);
// Configure retry settings
let retry_config = SseTransportRetryConfig {
max_times: Some(3),
min_duration: Duration::from_secs(1),
};
// Initialize oauth state machine
let mut oauth_state = OAuthState::new(&server_url, None)
.await
.context("Failed to initialize oauth state machine")?;
oauth_state
.start_authorization(&["mcp", "profile", "email"], MCP_REDIRECT_URI)
.await
.context("Failed to start authorization")?;
// Output authorization URL to user
let mut output = BufWriter::new(tokio::io::stdout());
output.write_all(b"\n=== MCP OAuth Client ===\n\n").await?;
output
.write_all(b"Please open the following URL in your browser to authorize:\n\n")
.await?;
output
.write_all(oauth_state.get_authorization_url().await?.as_bytes())
.await?;
output
.write_all(b"\n\nWaiting for browser callback, please do not close this window...\n")
.await?;
output.flush().await?;
// Wait for authorization code
tracing::info!("Waiting for authorization code...");
let auth_code = code_receiver
.await
.context("Failed to get authorization code")?;
tracing::info!("Received authorization code: {}", auth_code);
// Exchange code for access token
tracing::info!("Exchanging authorization code for access token...");
oauth_state
.handle_callback(&auth_code)
.await
.context("Failed to handle callback")?;
tracing::info!("Successfully obtained access token");
output
.write_all(b"\nAuthorization successful! Access token obtained.\n\n")
.await?;
output.flush().await?;
// Create authorized transport, this transport is authorized by the oauth state machine
tracing::info!("Establishing authorized connection to MCP server...");
let transport =
match create_authorized_transport(MCP_SSE_URL.to_string(), oauth_state, Some(retry_config))
.await
{
Ok(t) => t,
Err(e) => {
tracing::error!("Failed to create authorized transport: {}", e);
return Err(anyhow::anyhow!("Connection failed: {}", e));
}
};
// Create client and connect to MCP server
let client_service = ClientInfo::default();
let client = client_service.serve(transport).await?;
tracing::info!("Successfully connected to MCP server");
// Test API requests
output
.write_all(b"Fetching available tools from server...\n")
.await?;
output.flush().await?;
match client.peer().list_all_tools().await {
Ok(tools) => {
output
.write_all(format!("Available tools: {}\n\n", tools.len()).as_bytes())
.await?;
for tool in tools {
output
.write_all(
format!(
"- {} ({})\n",
tool.name,
tool.description.unwrap_or_default()
)
.as_bytes(),
)
.await?;
}
}
Err(e) => {
output
.write_all(format!("Error fetching tools: {}\n", e).as_bytes())
.await?;
}
}
output
.write_all(b"\nFetching available prompts from server...\n")
.await?;
output.flush().await?;
match client.peer().list_all_prompts().await {
Ok(prompts) => {
output
.write_all(format!("Available prompts: {}\n\n", prompts.len()).as_bytes())
.await?;
for prompt in prompts {
output
.write_all(format!("- {}\n", prompt.name).as_bytes())
.await?;
}
}
Err(e) => {
output
.write_all(format!("Error fetching prompts: {}\n", e).as_bytes())
.await?;
}
}
output
.write_all(b"\nConnection established successfully. You are now authenticated with the MCP server.\n")
.await?;
output.flush().await?;
// Keep the program running, wait for user input to exit
output.write_all(b"\nPress Enter to exit...\n").await?;
output.flush().await?;
let mut input = String::new();
let mut reader = BufReader::new(tokio::io::stdin());
reader.read_line(&mut input).await?;
Ok(())
}

View file

@ -1,5 +1,3 @@
[package]
name = "mcp-server-examples"
version = "0.1.5"
@ -7,7 +5,7 @@ edition = "2024"
publish = false
[dependencies]
rmcp= { path = "../../crates/rmcp", features = ["server", "transport-sse-server", "transport-io"] }
rmcp= { path = "../../crates/rmcp", features = ["server", "transport-sse-server", "transport-io", "auth"] }
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "io-std", "signal"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@ -19,9 +17,15 @@ tracing-subscriber = { version = "0.3", features = [
"fmt",
] }
futures = "0.3"
rand = { version = "0.9" }
rand = { version = "0.8", features = ["std"] }
axum = { version = "0.8", features = ["macros"] }
schemars = { version = "0.8", optional = true }
reqwest = { version = "0.12", features = ["json"] }
chrono = "0.4"
uuid = { version = "1.6", features = ["v4", "serde"] }
serde_urlencoded = "0.7"
askama = { version = "0.14"}
# [dev-dependencies.'cfg(target_arch="linux")'.dependencies]
[dev-dependencies]
@ -43,4 +47,12 @@ path = "src/axum_router.rs"
[[example]]
name = "servers_generic_server"
path = "src/generic_service.rs"
path = "src/generic_service.rs"
[[example]]
name = "servers_auth_sse"
path = "src/auth_sse.rs"
[[example]]
name = "mcp_oauth_server"
path = "src/mcp_oauth_server.rs"

View file

@ -0,0 +1,183 @@
/// This example shows how to use the RMCP SSE server with OAuth authorization.
/// Use the inspector to view this server https://github.com/modelcontextprotocol/inspector
/// The default index page is available at http://127.0.0.1:8000/
/// # Get a token
/// curl http://127.0.0.1:8000/api/token/demo
/// # Connect to SSE using the token
/// curl -H "Authorization: Bearer demo-token" http://127.0.0.1:8000/sse
use std::{net::SocketAddr, sync::Arc, time::Duration};
use anyhow::Result;
use axum::{
Json, Router,
extract::{Path, State},
http::{HeaderMap, Request, StatusCode},
middleware::{self, Next},
response::{Html, Response},
routing::get,
};
use rmcp::transport::{SseServer, sse_server::SseServerConfig};
use tokio_util::sync::CancellationToken;
mod common;
use common::counter::Counter;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
const BIND_ADDRESS: &str = "127.0.0.1:8000";
const INDEX_HTML: &str = include_str!("html/sse_auth_index.html");
// A simple token store
struct TokenStore {
valid_tokens: Vec<String>,
}
impl TokenStore {
fn new() -> Self {
// For demonstration purposes, use more secure token management in production
Self {
valid_tokens: vec!["demo-token".to_string(), "test-token".to_string()],
}
}
fn is_valid(&self, token: &str) -> bool {
self.valid_tokens.contains(&token.to_string())
}
}
// Extract authorization token
fn extract_token(headers: &HeaderMap) -> Option<String> {
headers
.get("Authorization")
.and_then(|value| value.to_str().ok())
.and_then(|auth_header| {
auth_header
.strip_prefix("Bearer ")
.map(|stripped| stripped.to_string())
})
}
// Authorization middleware
async fn auth_middleware(
State(token_store): State<Arc<TokenStore>>,
headers: HeaderMap,
request: Request<axum::body::Body>,
next: Next,
) -> Result<Response, StatusCode> {
match extract_token(&headers) {
Some(token) if token_store.is_valid(&token) => {
// Token is valid, proceed with the request
Ok(next.run(request).await)
}
_ => {
// Token is invalid, return 401 error
Err(StatusCode::UNAUTHORIZED)
}
}
}
// Root path handler
async fn index() -> Html<&'static str> {
Html(INDEX_HTML)
}
// Health check endpoint
async fn health_check() -> &'static str {
"OK"
}
// Token generation endpoint (simplified example)
async fn get_token(Path(token_id): Path<String>) -> Result<Json<serde_json::Value>, StatusCode> {
// In a real application, you should authenticate the user and generate a real token
if token_id == "demo" || token_id == "test" {
let token = format!("{}-token", token_id);
Ok(Json(serde_json::json!({
"access_token": token,
"token_type": "Bearer",
"expires_in": 3600
})))
} else {
Err(StatusCode::UNAUTHORIZED)
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "debug".to_string().into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
// Create token store
let token_store = Arc::new(TokenStore::new());
// Set up port
let addr = BIND_ADDRESS.parse::<SocketAddr>()?;
// Create SSE server configuration
let sse_config = SseServerConfig {
bind: addr,
sse_path: "/sse".to_string(),
post_path: "/message".to_string(),
ct: CancellationToken::new(),
sse_keep_alive: Some(Duration::from_secs(15)),
};
// Create SSE server
let (sse_server, sse_router) = SseServer::new(sse_config);
// Create API routes
let api_routes = Router::new()
.route("/health", get(health_check))
.route("/token/{token_id}", get(get_token));
// Create protected SSE routes (require authorization)
let protected_sse_router = sse_router.layer(middleware::from_fn_with_state(
token_store.clone(),
auth_middleware,
));
// Create main router, public endpoints don't require authorization
let app = Router::new()
.route("/", get(index))
.nest("/api", api_routes)
.merge(protected_sse_router)
.with_state(());
// Start server and register service
let listener = tokio::net::TcpListener::bind(addr).await?;
let ct = sse_server.config.ct.clone();
// Start SSE server with Counter service
sse_server.with_service(Counter::new);
// Handle signals for graceful shutdown
let cancel_token = ct.clone();
tokio::spawn(async move {
match tokio::signal::ctrl_c().await {
Ok(()) => {
println!("Received Ctrl+C, shutting down server...");
cancel_token.cancel();
}
Err(err) => {
eprintln!("Unable to listen for Ctrl+C signal: {}", err);
}
}
});
// Start HTTP server
tracing::info!("Server started on {}", addr);
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
// Wait for cancellation signal
ct.cancelled().await;
println!("Server is shutting down...");
});
if let Err(e) = server.await {
eprintln!("Server error: {}", e);
}
println!("Server has been shut down");
Ok(())
}

View file

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<title>MCP OAuth Server</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px auto; max-width: 800px; line-height: 1.6; }
h1, h2 { color: #333; }
code { background: #f4f4f4; padding: 2px 5px; border-radius: 3px; }
.endpoint { background: #f9f9f9; padding: 15px; border-radius: 5px; margin-bottom: 15px; }
.flow { background: #e8f5e9; padding: 15px; border-radius: 5px; margin-bottom: 15px; }
</style>
</head>
<body>
<h1>MCP OAuth Server</h1>
<p>This is an MCP server with OAuth 2.0 integration to a third-party authorization server.</p>
<h2>Available Endpoints:</h2>
<div class="endpoint">
<h3>Authorization Endpoint</h3>
<p><code>GET /oauth/authorize</code></p>
<p>Parameters:</p>
<ul>
<li><code>response_type</code> - Must be "code"</li>
<li><code>client_id</code> - Client identifier (e.g., "mcp-client")</li>
<li><code>redirect_uri</code> - URI to redirect after authorization</li>
<li><code>scope</code> - Optional requested scope</li>
<li><code>state</code> - Optional state value for CSRF prevention</li>
</ul>
</div>
<div class="endpoint">
<h3>Token Endpoint</h3>
<p><code>POST /oauth/token</code></p>
<p>Parameters:</p>
<ul>
<li><code>grant_type</code> - Must be "authorization_code"</li>
<li><code>code</code> - The authorization code</li>
<li><code>client_id</code> - Client identifier</li>
<li><code>client_secret</code> - Client secret</li>
<li><code>redirect_uri</code> - Redirect URI used in authorization request</li>
</ul>
</div>
<div class="endpoint">
<h3>MCP SSE Endpoints</h3>
<p><code>/mcp/sse</code> - SSE connection endpoint (requires OAuth token)</p>
<p><code>/mcp/message</code> - Message endpoint (requires OAuth token)</p>
</div>
<div class="flow">
<h2>OAuth Flow:</h2>
<ol>
<li>MCP Client initiates OAuth flow with this MCP Server</li>
<li>MCP Server redirects to Third-Party OAuth Server</li>
<li>User authenticates with Third-Party Server</li>
<li>Third-Party Server redirects back to MCP Server with auth code</li>
<li>MCP Server exchanges the code for a third-party access token</li>
<li>MCP Server generates its own token bound to the third-party session</li>
<li>MCP Server completes the OAuth flow with the MCP Client</li>
</ol>
</div>
</body>
</html>

View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
<head>
<title>RMCP Authorized SSE Server</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; line-height: 1.6; }
h1 { color: #333; }
.endpoint { background: #f4f4f4; padding: 10px; border-radius: 5px; }
pre { background: #eee; padding: 10px; border-radius: 5px; }
</style>
</head>
<body>
<h1>RMCP Authorized SSE Server</h1>
<p>This is a Server-Sent Events server example that requires OAuth authorization.</p>
<h2>Available Endpoints:</h2>
<ul>
<li class="endpoint"><code>/api/health</code> - Health check</li>
<li class="endpoint"><code>/api/token/{token_id}</code> - Get test token (available: demo, test)</li>
<li class="endpoint"><code>/sse</code> - SSE connection endpoint (requires authorization)</li>
<li class="endpoint"><code>/message</code> - Message sending endpoint (requires authorization)</li>
</ul>
<h2>Usage:</h2>
<pre>
# Get a token
curl http://127.0.0.1:8000/api/token/demo
# Connect to SSE using the token
curl -H "Authorization: Bearer demo-token" http://127.0.0.1:8000/sse
</pre>
</body>
</html>

View file

@ -0,0 +1,703 @@
use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Duration};
use anyhow::Result;
use askama::Template;
use axum::{
Json, Router,
body::Body,
extract::{Form, Query, State},
http::{Request, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
use rand::{Rng, distributions::Alphanumeric};
use rmcp::transport::{
SseServer,
auth::{
AuthorizationMetadata, ClientRegistrationRequest, ClientRegistrationResponse,
OAuthClientConfig,
},
sse_server::SseServerConfig,
};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
use tracing::{debug, error, info, warn};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use uuid::Uuid;
// Import Counter tool for MCP service
mod common;
use common::counter::Counter;
const BIND_ADDRESS: &str = "127.0.0.1:3000";
const INDEX_HTML: &str = include_str!("html/mcp_oauth_index.html");
// A easy way to manage MCP OAuth Store for managing tokens and sessions
#[derive(Clone, Debug)]
struct McpOAuthStore {
clients: Arc<RwLock<HashMap<String, OAuthClientConfig>>>,
auth_sessions: Arc<RwLock<HashMap<String, AuthSession>>>,
access_tokens: Arc<RwLock<HashMap<String, McpAccessToken>>>,
}
impl McpOAuthStore {
fn new() -> Self {
let mut clients = HashMap::new();
clients.insert(
"mcp-client".to_string(),
OAuthClientConfig {
client_id: "mcp-client".to_string(),
client_secret: Some("mcp-client-secret".to_string()),
scopes: vec!["profile".to_string(), "email".to_string()],
redirect_uri: "http://localhost:8080/callback".to_string(),
},
);
Self {
clients: Arc::new(RwLock::new(clients)),
auth_sessions: Arc::new(RwLock::new(HashMap::new())),
access_tokens: Arc::new(RwLock::new(HashMap::new())),
}
}
async fn validate_client(
&self,
client_id: &str,
redirect_uri: &str,
) -> Option<OAuthClientConfig> {
let clients = self.clients.read().await;
if let Some(client) = clients.get(client_id) {
if client.redirect_uri.contains(&redirect_uri.to_string()) {
return Some(client.clone());
}
}
None
}
async fn create_auth_session(
&self,
client_id: String,
scope: Option<String>,
state: Option<String>,
session_id: String,
) -> String {
let session = AuthSession {
client_id,
scope,
_state: state,
_created_at: chrono::Utc::now(),
auth_token: None,
};
self.auth_sessions
.write()
.await
.insert(session_id.clone(), session);
session_id
}
async fn update_auth_session_token(
&self,
session_id: &str,
token: AuthToken,
) -> Result<(), String> {
let mut sessions = self.auth_sessions.write().await;
if let Some(session) = sessions.get_mut(session_id) {
session.auth_token = Some(token);
Ok(())
} else {
Err("Session not found".to_string())
}
}
async fn create_mcp_token(&self, session_id: &str) -> Result<McpAccessToken, String> {
let sessions = self.auth_sessions.read().await;
if let Some(session) = sessions.get(session_id) {
if let Some(auth_token) = &session.auth_token {
let access_token = format!("mcp-token-{}", Uuid::new_v4());
let token = McpAccessToken {
access_token: access_token.clone(),
token_type: "Bearer".to_string(),
expires_in: 3600,
refresh_token: format!("mcp-refresh-{}", Uuid::new_v4()),
scope: session.scope.clone(),
auth_token: auth_token.clone(),
client_id: session.client_id.clone(),
};
self.access_tokens
.write()
.await
.insert(access_token.clone(), token.clone());
Ok(token)
} else {
Err("No third-party token available for session".to_string())
}
} else {
Err("Session not found".to_string())
}
}
async fn validate_token(&self, token: &str) -> Option<McpAccessToken> {
self.access_tokens.read().await.get(token).cloned()
}
}
// a simple session record for auth session
#[derive(Clone, Debug)]
struct AuthSession {
client_id: String,
scope: Option<String>,
_state: Option<String>,
_created_at: chrono::DateTime<chrono::Utc>,
auth_token: Option<AuthToken>,
}
// a simple token record for auth token
// not used oauth2 token for avoid include oauth2 crate in this example
#[derive(Clone, Debug, Serialize, Deserialize)]
struct AuthToken {
access_token: String,
token_type: String,
expires_in: u64,
refresh_token: String,
scope: Option<String>,
}
// a simple token record for mcp token ,
// not used oauth2 token for avoid include oauth2 crate in this example
#[derive(Clone, Debug, Serialize)]
struct McpAccessToken {
access_token: String,
token_type: String,
expires_in: u64,
refresh_token: String,
scope: Option<String>,
auth_token: AuthToken,
client_id: String,
}
#[derive(Debug, Deserialize)]
struct AuthorizeQuery {
#[allow(dead_code)]
response_type: String,
client_id: String,
redirect_uri: String,
scope: Option<String>,
state: Option<String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct TokenRequest {
grant_type: String,
#[serde(default)]
code: String,
#[serde(default)]
client_id: String,
#[serde(default)]
client_secret: String,
#[serde(default)]
redirect_uri: String,
#[serde(default)]
code_verifier: Option<String>,
#[serde(default)]
refresh_token: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct UserInfo {
sub: String,
name: String,
email: String,
username: String,
}
fn generate_random_string(length: usize) -> String {
rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(length)
.map(char::from)
.collect()
}
// Root path handler
async fn index() -> Html<&'static str> {
Html(INDEX_HTML)
}
#[derive(Template)]
#[template(path = "mcp_oauth_authorize.html")]
struct OAuthAuthorizeTemplate {
client_id: String,
redirect_uri: String,
scope: String,
state: String,
scopes: String,
}
// Initial OAuth authorize endpoint
async fn oauth_authorize(
Query(params): Query<AuthorizeQuery>,
State(state): State<Arc<McpOAuthStore>>,
) -> impl IntoResponse {
debug!("doing oauth_authorize");
if let Some(_client) = state
.validate_client(&params.client_id, &params.redirect_uri)
.await
{
let template = OAuthAuthorizeTemplate {
client_id: params.client_id,
redirect_uri: params.redirect_uri,
scope: params.scope.clone().unwrap_or_default(),
state: params.state.clone().unwrap_or_default(),
scopes: params
.scope
.clone()
.unwrap_or_else(|| "Basic scope".to_string()),
};
Html(template.render().unwrap()).into_response()
} else {
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "invalid client id or redirect uri"
})),
)
.into_response()
}
}
// handle approval of authorization
#[derive(Debug, Deserialize)]
struct ApprovalForm {
client_id: String,
redirect_uri: String,
scope: String,
state: String,
approved: String,
}
async fn oauth_approve(
State(state): State<Arc<McpOAuthStore>>,
Form(form): Form<ApprovalForm>,
) -> impl IntoResponse {
if form.approved != "true" {
// user rejected the authorization request
let redirect_url = format!(
"{}?error=access_denied&error_description={}{}",
form.redirect_uri,
"user rejected the authorization request",
if form.state.is_empty() {
"".to_string()
} else {
format!("&state={}", form.state)
}
);
return Redirect::to(&redirect_url).into_response();
}
// user approved the authorization request, generate authorization code
let session_id = Uuid::new_v4().to_string();
let auth_code = format!("mcp-code-{}", session_id);
// create new session record authorization information
let session_id = state
.create_auth_session(
form.client_id.clone(),
Some(form.scope.clone()),
Some(form.state.clone()),
session_id.clone(),
)
.await;
// create token
let created_token = AuthToken {
access_token: format!("tp-token-{}", Uuid::new_v4()),
token_type: "Bearer".to_string(),
expires_in: 3600,
refresh_token: format!("tp-refresh-{}", Uuid::new_v4()),
scope: Some(form.scope),
};
// update session token
if let Err(e) = state
.update_auth_session_token(&session_id, created_token)
.await
{
error!("Failed to update session token: {}", e);
}
// redirect back to client, with authorization code
let redirect_url = format!(
"{}?code={}{}",
form.redirect_uri,
auth_code,
if form.state.is_empty() {
"".to_string()
} else {
format!("&state={}", form.state)
}
);
info!("authorization approved, redirecting to: {}", redirect_url);
Redirect::to(&redirect_url).into_response()
}
// Handle token request from the MCP client
async fn oauth_token(
State(state): State<Arc<McpOAuthStore>>,
request: axum::http::Request<Body>,
) -> impl IntoResponse {
info!("Received token request");
let bytes = match axum::body::to_bytes(request.into_body(), usize::MAX).await {
Ok(bytes) => bytes,
Err(e) => {
error!("can't read request body: {}", e);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "can't read request body"
})),
)
.into_response();
}
};
let body_str = String::from_utf8_lossy(&bytes);
info!("request body: {}", body_str);
let token_req = match serde_urlencoded::from_bytes::<TokenRequest>(&bytes) {
Ok(form) => {
info!("successfully parsed form data: {:?}", form);
form
}
Err(e) => {
error!("can't parse form data: {}", e);
return (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": format!("can't parse form data: {}", e)
})),
)
.into_response();
}
};
if token_req.grant_type == "refresh_token" {
warn!("this easy server only support authorization_code now");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "unsupported_grant_type",
"error_description": "only authorization_code is supported"
})),
)
.into_response();
}
if token_req.grant_type != "authorization_code" {
info!("unsupported grant type: {}", token_req.grant_type);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "unsupported_grant_type",
"error_description": "only authorization_code is supported"
})),
)
.into_response();
}
// get session_id from code
if !token_req.code.starts_with("mcp-code-") {
info!("invalid authorization code: {}", token_req.code);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_grant",
"error_description": "invalid authorization code"
})),
)
.into_response();
}
// handle empty client_id
let client_id = if token_req.client_id.is_empty() {
"mcp-client".to_string()
} else {
token_req.client_id.clone()
};
// validate client
match state
.validate_client(&client_id, &token_req.redirect_uri)
.await
{
Some(_) => {
let session_id = token_req.code.replace("mcp-code-", "");
info!("got session id: {}", session_id);
// create mcp access token
match state.create_mcp_token(&session_id).await {
Ok(token) => {
info!("successfully created access token");
(
StatusCode::OK,
Json(serde_json::json!({
"access_token": token.access_token,
"token_type": token.token_type,
"expires_in": token.expires_in,
"refresh_token": token.refresh_token,
"scope": token.scope,
})),
)
.into_response()
}
Err(e) => {
error!("failed to create access token: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "server_error",
"error_description": format!("failed to create access token: {}", e)
})),
)
.into_response()
}
}
}
None => {
info!(
"invalid client id or redirect uri: {} / {}",
client_id, token_req.redirect_uri
);
(
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_client",
"error_description": "invalid client id or redirect uri"
})),
)
.into_response()
}
}
}
// Auth middleware for SSE connections
async fn validate_token_middleware(
State(token_store): State<Arc<McpOAuthStore>>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
debug!("validate_token_middleware");
// Extract the access token from the Authorization header
let auth_header = request.headers().get("Authorization");
let token = match auth_header {
Some(header) => {
let header_str = header.to_str().unwrap_or("");
if let Some(stripped) = header_str.strip_prefix("Bearer ") {
stripped.to_string()
} else {
return StatusCode::UNAUTHORIZED.into_response();
}
}
None => {
return StatusCode::UNAUTHORIZED.into_response();
}
};
// Validate the token
match token_store.validate_token(&token).await {
Some(_) => next.run(request).await,
None => StatusCode::UNAUTHORIZED.into_response(),
}
}
// handle oauth server metadata request
async fn oauth_authorization_server() -> impl IntoResponse {
let metadata = AuthorizationMetadata {
authorization_endpoint: format!("http://{}/oauth/authorize", BIND_ADDRESS),
token_endpoint: format!("http://{}/oauth/token", BIND_ADDRESS),
scopes_supported: Some(vec!["profile".to_string(), "email".to_string()]),
registration_endpoint: format!("http://{}/oauth/register", BIND_ADDRESS),
issuer: Some(BIND_ADDRESS.to_string()),
jwks_uri: Some(format!("http://{}/oauth/jwks", BIND_ADDRESS)),
};
debug!("metadata: {:?}", metadata);
(StatusCode::OK, Json(metadata))
}
// handle client registration request
async fn oauth_register(
State(state): State<Arc<McpOAuthStore>>,
Json(req): Json<ClientRegistrationRequest>,
) -> impl IntoResponse {
debug!("register request: {:?}", req);
if req.redirect_uris.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "at least one redirect uri is required"
})),
)
.into_response();
}
// generate client id and secret
let client_id = format!("client-{}", Uuid::new_v4());
let client_secret = generate_random_string(32);
let client = OAuthClientConfig {
client_id: client_id.clone(),
client_secret: Some(client_secret.clone()),
redirect_uri: req.redirect_uris[0].clone(),
scopes: vec![],
};
state
.clients
.write()
.await
.insert(client_id.clone(), client);
// return client information
let response = ClientRegistrationResponse {
client_id,
client_secret,
client_name: req.client_name,
redirect_uris: req.redirect_uris,
};
(StatusCode::CREATED, Json(response)).into_response()
}
// Log all HTTP requests
async fn log_request(request: Request<Body>, next: Next) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
let version = request.version();
// Log headers
let headers = request.headers().clone();
let mut header_log = String::new();
for (key, value) in headers.iter() {
let value_str = value.to_str().unwrap_or("<binary>");
header_log.push_str(&format!("\n {}: {}", key, value_str));
}
// Try to get request body for form submissions
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let request_info = if content_type.contains("application/x-www-form-urlencoded")
|| content_type.contains("application/json")
{
format!(
"{} {} {:?}{}\nContent-Type: {}",
method, uri, version, header_log, content_type
)
} else {
format!("{} {} {:?}{}", method, uri, version, header_log)
};
info!("REQUEST: {}", request_info);
// Call the actual handler
let response = next.run(request).await;
// Log response status
let status = response.status();
info!("RESPONSE: {} for {} {}", status, method, uri);
response
}
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "debug".to_string().into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
// Create the OAuth store
let oauth_store = Arc::new(McpOAuthStore::new());
// Set up port
let addr = BIND_ADDRESS.parse::<SocketAddr>()?;
// Create SSE server configuration for MCP
let sse_config = SseServerConfig {
bind: addr,
sse_path: "/mcp/sse".to_string(),
post_path: "/mcp/message".to_string(),
ct: CancellationToken::new(),
sse_keep_alive: Some(Duration::from_secs(15)),
};
// Create SSE server
let (sse_server, sse_router) = SseServer::new(sse_config);
// Create protected SSE routes (require authorization)
let protected_sse_router = sse_router.layer(middleware::from_fn_with_state(
oauth_store.clone(),
validate_token_middleware,
));
// Create HTTP router with request logging middleware
let app = Router::new()
.route("/", get(index))
.route("/mcp", get(index))
.route(
"/.well-known/oauth-authorization-server",
get(oauth_authorization_server),
)
.route("/oauth/authorize", get(oauth_authorize))
.route("/oauth/approve", post(oauth_approve))
.route("/oauth/token", post(oauth_token))
.route("/oauth/register", post(oauth_register))
// .merge(protected_sse_router)
.with_state(oauth_store.clone())
.layer(middleware::from_fn(log_request));
let app = app.merge(protected_sse_router);
// Register token validation middleware for SSE
let cancel_token = sse_server.config.ct.clone();
// Handle Ctrl+C
let cancel_token2 = sse_server.config.ct.clone();
// Start SSE server with Counter service
sse_server.with_service(Counter::new);
// Start HTTP server
info!("MCP OAuth Server started on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
let server = axum::serve(listener, app).with_graceful_shutdown(async move {
cancel_token.cancelled().await;
info!("Server is shutting down");
});
tokio::spawn(async move {
match tokio::signal::ctrl_c().await {
Ok(()) => {
info!("Received Ctrl+C, shutting down");
cancel_token2.cancel();
}
Err(e) => error!("Failed to listen for Ctrl+C: {}", e),
}
});
if let Err(e) = server.await {
error!("Server error: {}", e);
}
Ok(())
}

View file

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCP OAuth</title>
<style>
:root {
--primary-color: #4285f4;
--secondary-color: #f1f1f1;
--text-color: #333;
--border-color: #ddd;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #f8f9fa;
color: var(--text-color);
}
.container {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-width: 600px;
width: 90%;
margin: 1rem;
}
h1 {
margin: 0 0 1.5rem 0;
font-size: 1.8rem;
text-align: center;
}
.client-info {
background: var(--secondary-color);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
}
.btn-group {
display: flex;
gap: 1rem;
justify-content: center;
}
.btn {
padding: 0.75rem 1.5rem;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
border: none;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: #3367d6;
}
.btn-secondary {
background-color: var(--secondary-color);
color: var(--text-color);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background-color: #e0e0e0;
}
</style>
</head>
<body>
<div class="container">
<h1>MCP OAuth</h1>
<div class="client-info">
<p><strong>{{ client_id }}</strong> requests access to your account.</p>
<p>requested scopes: {{ scopes }}</p>
</div>
<form action="/oauth/approve" method="post">
<input type="hidden" name="client_id" value="{{ client_id }}">
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
<input type="hidden" name="scope" value="{{ scope }}">
<input type="hidden" name="state" value="{{ state }}">
<div class="btn-group">
<button type="submit" name="approved" value="true" class="btn btn-primary">Approve</button>
<button type="submit" name="approved" value="false" class="btn btn-secondary">Reject</button>
</div>
</form>
</div>
</body>
</html>