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:
parent
b1542a6153
commit
afb8a905e5
15 changed files with 2480 additions and 7 deletions
|
|
@ -179,6 +179,10 @@ For many cases you need to manage several service in a collection, you can call
|
||||||
let service = service.into_dyn();
|
let service = service.into_dyn();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### OAuth Support
|
||||||
|
|
||||||
|
See [docs/OAUTH_SUPPORT.md](docs/OAUTH_SUPPORT.md) for details.
|
||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
See [examples](examples/README.md)
|
See [examples](examples/README.md)
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,10 @@ tracing = { version = "0.1" }
|
||||||
tokio-util = { version = "0.7" }
|
tokio-util = { version = "0.7" }
|
||||||
pin-project-lite = "0.2"
|
pin-project-lite = "0.2"
|
||||||
paste = { version = "1", optional = true }
|
paste = { version = "1", optional = true }
|
||||||
|
|
||||||
|
# oauth2 support
|
||||||
|
oauth2 = { version = "5.0", optional = true }
|
||||||
|
|
||||||
# for auto generate schema
|
# for auto generate schema
|
||||||
schemars = { version = "0.8", optional = true }
|
schemars = { version = "0.8", optional = true }
|
||||||
|
|
||||||
|
|
@ -70,6 +74,8 @@ transport-sse-server = [
|
||||||
]
|
]
|
||||||
# transport-ws = ["transport-io", "dep:tokio-tungstenite"]
|
# transport-ws = ["transport-io", "dep:tokio-tungstenite"]
|
||||||
tower = ["dep:tower-service"]
|
tower = ["dep:tower-service"]
|
||||||
|
auth = ["dep:oauth2", "dep:reqwest", "dep:url"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
schemars = { version = "0.8" }
|
schemars = { version = "0.8" }
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,11 @@ pub mod sse;
|
||||||
#[cfg(feature = "transport-sse")]
|
#[cfg(feature = "transport-sse")]
|
||||||
pub use sse::SseTransport;
|
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")]
|
// #[cfg(feature = "tower")]
|
||||||
// pub mod tower;
|
// pub mod tower;
|
||||||
|
|
||||||
|
|
@ -64,6 +69,11 @@ pub mod sse_server;
|
||||||
#[cfg(feature = "transport-sse-server")]
|
#[cfg(feature = "transport-sse-server")]
|
||||||
pub use sse_server::SseServer;
|
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")]
|
// #[cfg(feature = "transport-ws")]
|
||||||
// pub mod ws;
|
// pub mod ws;
|
||||||
|
|
||||||
|
|
|
||||||
718
crates/rmcp/src/transport/auth.rs
Normal file
718
crates/rmcp/src/transport/auth.rs
Normal 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(®istration_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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
crates/rmcp/src/transport/sse_auth.rs
Normal file
183
crates/rmcp/src/transport/sse_auth.rs
Normal 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
125
docs/OAUTH_SUPPORT.md
Normal 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)
|
||||||
|
|
@ -11,7 +11,8 @@ rmcp = { path = "../../crates/rmcp", features = [
|
||||||
"client",
|
"client",
|
||||||
"transport-sse",
|
"transport-sse",
|
||||||
"transport-child-process",
|
"transport-child-process",
|
||||||
"tower"
|
"tower",
|
||||||
|
"auth"
|
||||||
] }
|
] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|
@ -21,8 +22,9 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
url = "2.4"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
|
axum = "0.8"
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "clients_sse"
|
name = "clients_sse"
|
||||||
|
|
@ -40,3 +42,6 @@ path = "src/everything_stdio.rs"
|
||||||
name = "clients_collection"
|
name = "clients_collection"
|
||||||
path = "src/collection.rs"
|
path = "src/collection.rs"
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "oauth_client"
|
||||||
|
path = "src/oauth_client.rs"
|
||||||
93
examples/clients/src/callback.html
Normal file
93
examples/clients/src/callback.html
Normal 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>
|
||||||
228
examples/clients/src/oauth_client.rs
Normal file
228
examples/clients/src/oauth_client.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
|
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "mcp-server-examples"
|
name = "mcp-server-examples"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
|
|
@ -7,7 +5,7 @@ edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
[dependencies]
|
[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"] }
|
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread", "io-std", "signal"] }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|
@ -19,9 +17,15 @@ tracing-subscriber = { version = "0.3", features = [
|
||||||
"fmt",
|
"fmt",
|
||||||
] }
|
] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rand = { version = "0.9" }
|
rand = { version = "0.8", features = ["std"] }
|
||||||
axum = { version = "0.8", features = ["macros"] }
|
axum = { version = "0.8", features = ["macros"] }
|
||||||
schemars = { version = "0.8", optional = true }
|
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.'cfg(target_arch="linux")'.dependencies]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|
@ -43,4 +47,12 @@ path = "src/axum_router.rs"
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "servers_generic_server"
|
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"
|
||||||
183
examples/servers/src/auth_sse.rs
Normal file
183
examples/servers/src/auth_sse.rs
Normal 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(())
|
||||||
|
}
|
||||||
64
examples/servers/src/html/mcp_oauth_index.html
Normal file
64
examples/servers/src/html/mcp_oauth_index.html
Normal 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>
|
||||||
33
examples/servers/src/html/sse_auth_index.html
Normal file
33
examples/servers/src/html/sse_auth_index.html
Normal 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>
|
||||||
703
examples/servers/src/mcp_oauth_server.rs
Normal file
703
examples/servers/src/mcp_oauth_server.rs
Normal 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(¶ms.client_id, ¶ms.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(())
|
||||||
|
}
|
||||||
106
examples/servers/templates/mcp_oauth_authorize.html
Normal file
106
examples/servers/templates/mcp_oauth_authorize.html
Normal 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>
|
||||||
Loading…
Reference in a new issue