feat: implement OAuth 2.0 Client Credentials flow (#707)
* feat: implement OAuth 2.0 Client Credentials flow * fix: address SEP-1046 review findings * fix: validate HTTPS on JWT token endpoint
This commit is contained in:
parent
60a5518efb
commit
bb6c8043bf
6 changed files with 1022 additions and 1 deletions
|
|
@ -27,6 +27,8 @@ pin-project-lite = "0.2"
|
|||
pastey = { version = "0.2.0", optional = true }
|
||||
# oauth2 support
|
||||
oauth2 = { version = "5.0", optional = true, default-features = false }
|
||||
# JWT signing for client credentials (private_key_jwt)
|
||||
jsonwebtoken = { version = "9", optional = true }
|
||||
|
||||
# for auto generate schema
|
||||
schemars = { version = "1.0", optional = true, features = ["chrono04"] }
|
||||
|
|
@ -130,12 +132,14 @@ transport-streamable-http-server-session = [
|
|||
# transport-ws = ["transport-io", "dep:tokio-tungstenite"]
|
||||
tower = ["dep:tower-service"]
|
||||
auth = ["dep:oauth2", "__reqwest", "dep:url"]
|
||||
auth-client-credentials-jwt = ["auth", "dep:jsonwebtoken", "uuid"]
|
||||
schemars = ["dep:schemars"]
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
schemars = { version = "1.1.0", features = ["chrono04"] }
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
|
||||
url = "2.4"
|
||||
anyhow = "1.0"
|
||||
tracing-subscriber = { version = "0.3", features = [
|
||||
"env-filter",
|
||||
|
|
@ -251,3 +255,8 @@ path = "tests/test_custom_headers.rs"
|
|||
name = "test_sse_concurrent_streams"
|
||||
required-features = ["server", "client", "transport-streamable-http-server", "transport-streamable-http-client", "reqwest"]
|
||||
path = "tests/test_sse_concurrent_streams.rs"
|
||||
|
||||
[[test]]
|
||||
name = "test_client_credentials"
|
||||
required-features = ["auth"]
|
||||
path = "tests/test_client_credentials.rs"
|
||||
|
|
|
|||
|
|
@ -93,10 +93,13 @@ pub use io::stdio;
|
|||
|
||||
#[cfg(feature = "auth")]
|
||||
pub mod auth;
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
pub use auth::JwtSigningAlgorithm;
|
||||
#[cfg(feature = "auth")]
|
||||
pub use auth::{
|
||||
AuthClient, AuthError, AuthorizationManager, AuthorizationSession, AuthorizedHttpClient,
|
||||
CredentialStore, InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore,
|
||||
ClientCredentialsConfig, CredentialStore, EXTENSION_OAUTH_CLIENT_CREDENTIALS,
|
||||
InMemoryCredentialStore, InMemoryStateStore, ScopeUpgradeConfig, StateStore,
|
||||
StoredAuthorizationState, StoredCredentials, WWWAuthenticateParams,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -315,6 +315,13 @@ pub enum AuthError {
|
|||
required_scope: String,
|
||||
upgrade_url: Option<String>,
|
||||
},
|
||||
|
||||
#[error("Client credentials error: {0}")]
|
||||
ClientCredentialsError(String),
|
||||
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
#[error("JWT signing error: {0}")]
|
||||
JwtSigningError(String),
|
||||
}
|
||||
|
||||
/// oauth2 metadata
|
||||
|
|
@ -402,6 +409,105 @@ type OAuthClient = oauth2::Client<
|
|||
>;
|
||||
type Credentials = (String, Option<OAuthTokenResponse>);
|
||||
|
||||
/// OAuth 2.0 extension identifier for client credentials flow (SEP-1046)
|
||||
pub const EXTENSION_OAUTH_CLIENT_CREDENTIALS: &str =
|
||||
"io.modelcontextprotocol/oauth-client-credentials";
|
||||
|
||||
/// JWT signing algorithm for private_key_jwt authentication (SEP-1046)
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum JwtSigningAlgorithm {
|
||||
RS256,
|
||||
RS384,
|
||||
RS512,
|
||||
ES256,
|
||||
ES384,
|
||||
}
|
||||
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
impl JwtSigningAlgorithm {
|
||||
fn to_jsonwebtoken_algorithm(self) -> jsonwebtoken::Algorithm {
|
||||
match self {
|
||||
JwtSigningAlgorithm::RS256 => jsonwebtoken::Algorithm::RS256,
|
||||
JwtSigningAlgorithm::RS384 => jsonwebtoken::Algorithm::RS384,
|
||||
JwtSigningAlgorithm::RS512 => jsonwebtoken::Algorithm::RS512,
|
||||
JwtSigningAlgorithm::ES256 => jsonwebtoken::Algorithm::ES256,
|
||||
JwtSigningAlgorithm::ES384 => jsonwebtoken::Algorithm::ES384,
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
JwtSigningAlgorithm::RS256 => "RS256",
|
||||
JwtSigningAlgorithm::RS384 => "RS384",
|
||||
JwtSigningAlgorithm::RS512 => "RS512",
|
||||
JwtSigningAlgorithm::ES256 => "ES256",
|
||||
JwtSigningAlgorithm::ES384 => "ES384",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for OAuth 2.0 Client Credentials flow (SEP-1046)
|
||||
///
|
||||
/// This supports two authentication methods:
|
||||
/// - `ClientSecret`: credentials sent in the request body
|
||||
/// - `PrivateKeyJwt`: RFC 7523 signed JWT assertion (requires `auth-client-credentials-jwt` feature)
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ClientCredentialsConfig {
|
||||
/// Client secret authentication (credentials in request body)
|
||||
ClientSecret {
|
||||
client_id: String,
|
||||
client_secret: String,
|
||||
scopes: Vec<String>,
|
||||
resource: Option<String>,
|
||||
},
|
||||
/// Private key JWT authentication (RFC 7523)
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
PrivateKeyJwt {
|
||||
client_id: String,
|
||||
signing_key: Vec<u8>,
|
||||
signing_algorithm: JwtSigningAlgorithm,
|
||||
/// Override the `aud` claim in the JWT assertion; defaults to token_endpoint
|
||||
token_endpoint_audience: Option<String>,
|
||||
scopes: Vec<String>,
|
||||
resource: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ClientCredentialsConfig {
|
||||
fn client_id(&self) -> &str {
|
||||
match self {
|
||||
ClientCredentialsConfig::ClientSecret { client_id, .. } => client_id,
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
ClientCredentialsConfig::PrivateKeyJwt { client_id, .. } => client_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn scopes(&self) -> &[String] {
|
||||
match self {
|
||||
ClientCredentialsConfig::ClientSecret { scopes, .. } => scopes,
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
ClientCredentialsConfig::PrivateKeyJwt { scopes, .. } => scopes,
|
||||
}
|
||||
}
|
||||
|
||||
fn resource(&self) -> Option<&str> {
|
||||
match self {
|
||||
ClientCredentialsConfig::ClientSecret { resource, .. } => resource.as_deref(),
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
ClientCredentialsConfig::PrivateKeyJwt { resource, .. } => resource.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_method(&self) -> &str {
|
||||
match self {
|
||||
ClientCredentialsConfig::ClientSecret { .. } => "client_secret_post",
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
ClientCredentialsConfig::PrivateKeyJwt { .. } => "private_key_jwt",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for scope upgrade behavior
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScopeUpgradeConfig {
|
||||
|
|
@ -1489,6 +1595,394 @@ impl AuthorizationManager {
|
|||
Some((trimmed[..end].to_string(), leading_ws + end))
|
||||
}
|
||||
}
|
||||
|
||||
// -- Client Credentials flow (SEP-1046) --
|
||||
|
||||
/// Validate that the authorization server metadata supports the requested
|
||||
/// client credentials authentication method.
|
||||
///
|
||||
/// For `client_secret_post`, checks `token_endpoint_auth_methods_supported`.
|
||||
/// For `private_key_jwt`, additionally checks `token_endpoint_auth_signing_alg_values_supported`.
|
||||
/// When the metadata field is absent, the method is permissive (assumes support).
|
||||
pub fn validate_client_credentials_metadata(
|
||||
&self,
|
||||
config: &ClientCredentialsConfig,
|
||||
) -> Result<(), AuthError> {
|
||||
let Some(metadata) = self.metadata.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(methods) = metadata
|
||||
.additional_fields
|
||||
.get("token_endpoint_auth_methods_supported")
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
let is_supported = match config {
|
||||
ClientCredentialsConfig::ClientSecret { .. } => {
|
||||
// Accept either client_secret_post (request body) or
|
||||
// client_secret_basic (HTTP Basic) per the MCP auth spec.
|
||||
methods.iter().any(|m| {
|
||||
matches!(
|
||||
m.as_str(),
|
||||
Some("client_secret_post") | Some("client_secret_basic")
|
||||
)
|
||||
})
|
||||
}
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
ClientCredentialsConfig::PrivateKeyJwt { .. } => methods
|
||||
.iter()
|
||||
.any(|m| m.as_str() == Some("private_key_jwt")),
|
||||
};
|
||||
if !is_supported {
|
||||
let required_method = config.auth_method();
|
||||
let supported: Vec<&str> = methods.iter().filter_map(|m| m.as_str()).collect();
|
||||
return Err(AuthError::ClientCredentialsError(format!(
|
||||
"Authorization server does not support auth method '{}'. Supported: {:?}",
|
||||
required_method, supported
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
if let ClientCredentialsConfig::PrivateKeyJwt {
|
||||
signing_algorithm, ..
|
||||
} = config
|
||||
{
|
||||
if let Some(algs) = metadata
|
||||
.additional_fields
|
||||
.get("token_endpoint_auth_signing_alg_values_supported")
|
||||
.and_then(|v| v.as_array())
|
||||
{
|
||||
let alg_str = signing_algorithm.as_str();
|
||||
if !algs.iter().any(|a| a.as_str() == Some(alg_str)) {
|
||||
let supported: Vec<&str> = algs.iter().filter_map(|a| a.as_str()).collect();
|
||||
return Err(AuthError::ClientCredentialsError(format!(
|
||||
"Authorization server does not support signing algorithm '{}'. Supported: {:?}",
|
||||
alg_str, supported
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Configure the OAuth2 client for the client credentials flow.
|
||||
///
|
||||
/// Selects `client_secret_post` (request body) by default. Switches to
|
||||
/// `client_secret_basic` (HTTP Basic) only when the server advertises that
|
||||
/// method exclusively. For `PrivateKeyJwt`, no OAuth client state is needed
|
||||
/// here; the token request is built manually in `exchange_client_credentials_jwt`.
|
||||
pub fn configure_client_credentials(
|
||||
&mut self,
|
||||
config: &ClientCredentialsConfig,
|
||||
) -> Result<(), AuthError> {
|
||||
let metadata = self
|
||||
.metadata
|
||||
.as_ref()
|
||||
.ok_or(AuthError::NoAuthorizationSupport)?;
|
||||
|
||||
let token_url = TokenUrl::new(metadata.token_endpoint.clone())
|
||||
.map_err(|e| AuthError::OAuthError(format!("Invalid token URL: {}", e)))?;
|
||||
|
||||
// auth_url is required by the type but won't be used for client credentials
|
||||
let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone())
|
||||
.map_err(|e| AuthError::OAuthError(format!("Invalid authorization URL: {}", e)))?;
|
||||
|
||||
let client_id = ClientId::new(config.client_id().to_string());
|
||||
|
||||
let mut client_builder: OAuthClient = oauth2::Client::new(client_id)
|
||||
.set_auth_uri(auth_url)
|
||||
.set_token_uri(token_url);
|
||||
|
||||
match config {
|
||||
ClientCredentialsConfig::ClientSecret { client_secret, .. } => {
|
||||
client_builder =
|
||||
client_builder.set_client_secret(ClientSecret::new(client_secret.clone()));
|
||||
// Use client_secret_basic (HTTP Basic) when that is the only method
|
||||
// the server advertises; fall back to client_secret_post (request body).
|
||||
let only_basic = metadata
|
||||
.additional_fields
|
||||
.get("token_endpoint_auth_methods_supported")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
let (has_basic, has_post) =
|
||||
arr.iter()
|
||||
.fold((false, false), |(b, p), m| match m.as_str() {
|
||||
Some("client_secret_basic") => (true, p),
|
||||
Some("client_secret_post") => (b, true),
|
||||
_ => (b, p),
|
||||
});
|
||||
has_basic && !has_post
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if !only_basic {
|
||||
client_builder = client_builder.set_auth_type(AuthType::RequestBody);
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
ClientCredentialsConfig::PrivateKeyJwt { .. } => {
|
||||
// For JWT, client identity comes from the assertion's `sub` claim.
|
||||
// The request is built manually in exchange_client_credentials_jwt to
|
||||
// ensure client_id is not included in the body per RFC 7523 §3.
|
||||
}
|
||||
}
|
||||
|
||||
self.oauth_client = Some(client_builder);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exchange client credentials for an access token (SEP-1046).
|
||||
///
|
||||
/// For `ClientSecret`: sends credentials in the request body (or Authorization header
|
||||
/// for `client_secret_basic`) with scopes and resource.
|
||||
/// For `PrivateKeyJwt`: builds the request manually (no `client_id` in body per RFC 7523 §3).
|
||||
pub async fn exchange_client_credentials(
|
||||
&self,
|
||||
config: &ClientCredentialsConfig,
|
||||
) -> Result<OAuthTokenResponse, AuthError> {
|
||||
// The MCP auth spec requires the `resource` parameter in all token requests.
|
||||
if config.resource().is_none() {
|
||||
return Err(AuthError::ClientCredentialsError(
|
||||
"resource parameter is required by the MCP auth spec".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// For private_key_jwt, use a separate path that omits client_id from the request
|
||||
// body, as required by RFC 7523 §3 (client is identified by the JWT `sub` claim).
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
if matches!(config, ClientCredentialsConfig::PrivateKeyJwt { .. }) {
|
||||
return self.exchange_client_credentials_jwt(config).await;
|
||||
}
|
||||
|
||||
let oauth_client = self
|
||||
.oauth_client
|
||||
.as_ref()
|
||||
.ok_or_else(|| AuthError::InternalError("OAuth client not configured".to_string()))?;
|
||||
|
||||
let mut request = oauth_client.exchange_client_credentials();
|
||||
|
||||
for scope in config.scopes() {
|
||||
request = request.add_scope(Scope::new(scope.clone()));
|
||||
}
|
||||
|
||||
if let Some(resource) = config.resource() {
|
||||
request = request.add_extra_param("resource", resource);
|
||||
}
|
||||
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| AuthError::InternalError(e.to_string()))?;
|
||||
|
||||
let token_result = match request
|
||||
.request_async(&OAuthReqwestClient(http_client))
|
||||
.await
|
||||
{
|
||||
Ok(token) => token,
|
||||
Err(RequestTokenError::Parse(_, body)) => {
|
||||
match serde_json::from_slice::<OAuthTokenResponse>(&body) {
|
||||
Ok(parsed) => {
|
||||
warn!(
|
||||
"client credentials token exchange failed to parse completely but included a valid token response. Accepting it."
|
||||
);
|
||||
parsed
|
||||
}
|
||||
Err(parse_err) => {
|
||||
return Err(AuthError::ClientCredentialsError(format!(
|
||||
"Token exchange parse error: {}",
|
||||
parse_err
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(AuthError::ClientCredentialsError(format!(
|
||||
"Token exchange failed: {}",
|
||||
e
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
debug!("client credentials token result: {:?}", token_result);
|
||||
|
||||
let granted_scopes: Vec<String> = token_result
|
||||
.scopes()
|
||||
.map(|scopes| scopes.iter().map(|s| s.to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
*self.current_scopes.write().await = granted_scopes.clone();
|
||||
|
||||
let client_id = config.client_id().to_string();
|
||||
let stored = StoredCredentials {
|
||||
client_id,
|
||||
token_response: Some(token_result.clone()),
|
||||
granted_scopes,
|
||||
token_received_at: Some(Self::now_epoch_secs()),
|
||||
};
|
||||
self.credential_store.save(stored).await?;
|
||||
|
||||
Ok(token_result)
|
||||
}
|
||||
|
||||
/// Exchange client credentials using a JWT assertion (RFC 7523).
|
||||
///
|
||||
/// Builds the token request manually so that `client_id` is **not** included in the
|
||||
/// request body; client identity is conveyed solely by the `sub` claim in the assertion.
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
async fn exchange_client_credentials_jwt(
|
||||
&self,
|
||||
config: &ClientCredentialsConfig,
|
||||
) -> Result<OAuthTokenResponse, AuthError> {
|
||||
let ClientCredentialsConfig::PrivateKeyJwt {
|
||||
client_id,
|
||||
signing_key,
|
||||
signing_algorithm,
|
||||
token_endpoint_audience,
|
||||
scopes,
|
||||
resource,
|
||||
} = config
|
||||
else {
|
||||
return Err(AuthError::InternalError(
|
||||
"expected PrivateKeyJwt config".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let metadata = self
|
||||
.metadata
|
||||
.as_ref()
|
||||
.ok_or(AuthError::NoAuthorizationSupport)?;
|
||||
|
||||
// Validate that the token endpoint uses HTTPS before transmitting sensitive credentials.
|
||||
let token_endpoint_url = url::Url::parse(&metadata.token_endpoint).map_err(|e| {
|
||||
AuthError::ClientCredentialsError(format!(
|
||||
"Invalid token endpoint URL in authorization metadata: {e}"
|
||||
))
|
||||
})?;
|
||||
if token_endpoint_url.scheme() != "https" {
|
||||
return Err(AuthError::ClientCredentialsError(
|
||||
"Insecure token endpoint URL: HTTPS is required for client credentials flow"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let audience = token_endpoint_audience
|
||||
.as_deref()
|
||||
.unwrap_or(&metadata.token_endpoint);
|
||||
|
||||
let assertion =
|
||||
Self::build_jwt_assertion(client_id, audience, signing_key, *signing_algorithm)?;
|
||||
|
||||
let scope_str = scopes.join(" ");
|
||||
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
|
||||
serializer.append_pair("grant_type", "client_credentials");
|
||||
serializer.append_pair(
|
||||
"client_assertion_type",
|
||||
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
|
||||
);
|
||||
serializer.append_pair("client_assertion", &assertion);
|
||||
if !scope_str.is_empty() {
|
||||
serializer.append_pair("scope", &scope_str);
|
||||
}
|
||||
if let Some(res) = resource.as_deref() {
|
||||
serializer.append_pair("resource", res);
|
||||
}
|
||||
let body_str = serializer.finish();
|
||||
|
||||
let http_client = reqwest::ClientBuilder::new()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| AuthError::InternalError(e.to_string()))?;
|
||||
|
||||
let response = http_client
|
||||
.post(token_endpoint_url.as_str())
|
||||
.header("content-type", "application/x-www-form-urlencoded")
|
||||
.body(body_str)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AuthError::ClientCredentialsError(format!("Token exchange request failed: {e}"))
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.bytes().await.map_err(|e| {
|
||||
AuthError::ClientCredentialsError(format!("Failed to read token response: {e}"))
|
||||
})?;
|
||||
|
||||
if !status.is_success() {
|
||||
let msg = if let Ok(v) = serde_json::from_slice::<serde_json::Value>(&body) {
|
||||
let error = v.get("error").and_then(|e| e.as_str()).unwrap_or("unknown");
|
||||
let desc = v
|
||||
.get("error_description")
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("");
|
||||
format!("Token exchange failed: {error}: {desc}")
|
||||
} else {
|
||||
format!("Token exchange failed: HTTP {status}")
|
||||
};
|
||||
return Err(AuthError::ClientCredentialsError(msg));
|
||||
}
|
||||
|
||||
let token_result = serde_json::from_slice::<OAuthTokenResponse>(&body).map_err(|e| {
|
||||
AuthError::ClientCredentialsError(format!("Failed to parse token response: {e}"))
|
||||
})?;
|
||||
|
||||
debug!("client credentials JWT token result: {:?}", token_result);
|
||||
|
||||
let granted_scopes: Vec<String> = token_result
|
||||
.scopes()
|
||||
.map(|scopes| scopes.iter().map(|s| s.to_string()).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
*self.current_scopes.write().await = granted_scopes.clone();
|
||||
|
||||
let stored = StoredCredentials {
|
||||
client_id: client_id.clone(),
|
||||
token_response: Some(token_result.clone()),
|
||||
granted_scopes,
|
||||
token_received_at: Some(Self::now_epoch_secs()),
|
||||
};
|
||||
self.credential_store.save(stored).await?;
|
||||
|
||||
Ok(token_result)
|
||||
}
|
||||
|
||||
/// Build a JWT assertion per RFC 7523 for private_key_jwt authentication.
|
||||
#[cfg(feature = "auth-client-credentials-jwt")]
|
||||
fn build_jwt_assertion(
|
||||
client_id: &str,
|
||||
audience: &str,
|
||||
signing_key: &[u8],
|
||||
algorithm: JwtSigningAlgorithm,
|
||||
) -> Result<String, AuthError> {
|
||||
use serde_json::json;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let jti = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let claims = json!({
|
||||
"iss": client_id,
|
||||
"sub": client_id,
|
||||
"aud": audience,
|
||||
"iat": now,
|
||||
"exp": now + 300, // 5 minutes
|
||||
"jti": jti,
|
||||
});
|
||||
|
||||
let header = jsonwebtoken::Header::new(algorithm.to_jsonwebtoken_algorithm());
|
||||
let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(signing_key).or_else(|_| {
|
||||
jsonwebtoken::EncodingKey::from_ec_pem(signing_key).map_err(|e| {
|
||||
AuthError::JwtSigningError(format!("Failed to parse signing key: {}", e))
|
||||
})
|
||||
})?;
|
||||
|
||||
jsonwebtoken::encode(&header, &claims, &encoding_key)
|
||||
.map_err(|e| AuthError::JwtSigningError(format!("Failed to sign JWT: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
/// oauth2 authorization session, for guiding user to complete the authorization process
|
||||
|
|
@ -1907,6 +2401,41 @@ impl OAuthState {
|
|||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate using OAuth 2.0 Client Credentials flow (SEP-1046).
|
||||
///
|
||||
/// Transitions directly from `Unauthorized` to `Authorized`, skipping the
|
||||
/// interactive `Session` state entirely. Discovers metadata, configures the
|
||||
/// client, and exchanges credentials for an access token.
|
||||
pub async fn authenticate_client_credentials(
|
||||
&mut self,
|
||||
config: ClientCredentialsConfig,
|
||||
) -> Result<(), AuthError> {
|
||||
let OAuthState::Unauthorized(mut manager) = std::mem::replace(
|
||||
self,
|
||||
OAuthState::Unauthorized(AuthorizationManager::new(DEFAULT_EXCHANGE_URL).await?),
|
||||
) else {
|
||||
return Err(AuthError::InternalError(
|
||||
"Client credentials flow requires Unauthorized state".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// Discover metadata
|
||||
let metadata = manager.discover_metadata().await?;
|
||||
manager.metadata = Some(metadata);
|
||||
|
||||
// Validate server supports the requested auth method
|
||||
manager.validate_client_credentials_metadata(&config)?;
|
||||
|
||||
// Configure OAuth client
|
||||
manager.configure_client_credentials(&config)?;
|
||||
|
||||
// Exchange credentials for token
|
||||
manager.exchange_client_credentials(&config).await?;
|
||||
|
||||
*self = OAuthState::Authorized(manager);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -2869,4 +3398,186 @@ mod tests {
|
|||
let json = serde_json::to_value(&req).unwrap();
|
||||
assert!(!json.as_object().unwrap().contains_key("scope"));
|
||||
}
|
||||
|
||||
// -- client credentials (SEP-1046) --
|
||||
|
||||
#[tokio::test]
|
||||
async fn configure_client_credentials_uses_request_body_auth_for_client_secret() {
|
||||
let mut mgr = manager_with_metadata(None).await;
|
||||
let config = super::ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "my-m2m-client".to_string(),
|
||||
client_secret: "super-secret".to_string(),
|
||||
scopes: vec!["read".to_string()],
|
||||
resource: None,
|
||||
};
|
||||
mgr.configure_client_credentials(&config).unwrap();
|
||||
let oauth_client = mgr.oauth_client.as_ref().unwrap();
|
||||
assert!(matches!(oauth_client.auth_type(), AuthType::RequestBody));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configure_client_credentials_sets_correct_client_id() {
|
||||
let mut mgr = manager_with_metadata(None).await;
|
||||
let config = super::ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "my-m2m-client".to_string(),
|
||||
client_secret: "super-secret".to_string(),
|
||||
scopes: vec!["read".to_string()],
|
||||
resource: None,
|
||||
};
|
||||
mgr.configure_client_credentials(&config).unwrap();
|
||||
let oauth_client = mgr.oauth_client.as_ref().unwrap();
|
||||
assert_eq!(oauth_client.client_id().as_str(), "my-m2m-client");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configure_client_credentials_returns_error_without_metadata() {
|
||||
let mut mgr = AuthorizationManager::new("http://localhost").await.unwrap();
|
||||
let config = super::ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "id".to_string(),
|
||||
client_secret: "secret".to_string(),
|
||||
scopes: vec![],
|
||||
resource: None,
|
||||
};
|
||||
let err = mgr.configure_client_credentials(&config).unwrap_err();
|
||||
assert!(matches!(err, AuthError::NoAuthorizationSupport));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_client_credentials_metadata_rejects_unsupported_method() {
|
||||
let mut additional_fields = HashMap::new();
|
||||
additional_fields.insert(
|
||||
"token_endpoint_auth_methods_supported".to_string(),
|
||||
// Neither client_secret_post nor client_secret_basic — should be rejected.
|
||||
serde_json::json!(["tls_client_auth", "private_key_jwt"]),
|
||||
);
|
||||
let meta = AuthorizationMetadata {
|
||||
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||
token_endpoint: "http://localhost/token".to_string(),
|
||||
additional_fields,
|
||||
..Default::default()
|
||||
};
|
||||
let mgr = manager_with_metadata(Some(meta)).await;
|
||||
let config = super::ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "id".to_string(),
|
||||
client_secret: "secret".to_string(),
|
||||
scopes: vec![],
|
||||
resource: None,
|
||||
};
|
||||
let err = mgr
|
||||
.validate_client_credentials_metadata(&config)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("tls_client_auth"),
|
||||
"expected error to mention unsupported method, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_client_credentials_metadata_accepts_supported_method() {
|
||||
let mut additional_fields = HashMap::new();
|
||||
additional_fields.insert(
|
||||
"token_endpoint_auth_methods_supported".to_string(),
|
||||
serde_json::json!(["client_secret_post", "client_secret_basic"]),
|
||||
);
|
||||
let meta = AuthorizationMetadata {
|
||||
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||
token_endpoint: "http://localhost/token".to_string(),
|
||||
additional_fields,
|
||||
..Default::default()
|
||||
};
|
||||
let mgr = manager_with_metadata(Some(meta)).await;
|
||||
let config = super::ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "id".to_string(),
|
||||
client_secret: "secret".to_string(),
|
||||
scopes: vec![],
|
||||
resource: None,
|
||||
};
|
||||
mgr.validate_client_credentials_metadata(&config).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_client_credentials_metadata_permits_when_field_absent() {
|
||||
let mgr = manager_with_metadata(None).await;
|
||||
let config = super::ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "id".to_string(),
|
||||
client_secret: "secret".to_string(),
|
||||
scopes: vec![],
|
||||
resource: None,
|
||||
};
|
||||
mgr.validate_client_credentials_metadata(&config).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_client_credentials_metadata_accepts_client_secret_basic_only() {
|
||||
let mut additional_fields = HashMap::new();
|
||||
additional_fields.insert(
|
||||
"token_endpoint_auth_methods_supported".to_string(),
|
||||
serde_json::json!(["client_secret_basic"]),
|
||||
);
|
||||
let meta = AuthorizationMetadata {
|
||||
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||
token_endpoint: "http://localhost/token".to_string(),
|
||||
additional_fields,
|
||||
..Default::default()
|
||||
};
|
||||
let mgr = manager_with_metadata(Some(meta)).await;
|
||||
let config = super::ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "id".to_string(),
|
||||
client_secret: "secret".to_string(),
|
||||
scopes: vec![],
|
||||
resource: None,
|
||||
};
|
||||
// A server advertising only client_secret_basic must be accepted.
|
||||
mgr.validate_client_credentials_metadata(&config).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configure_client_credentials_uses_basic_auth_when_server_only_supports_basic() {
|
||||
let mut additional_fields = HashMap::new();
|
||||
additional_fields.insert(
|
||||
"token_endpoint_auth_methods_supported".to_string(),
|
||||
serde_json::json!(["client_secret_basic"]),
|
||||
);
|
||||
let meta = AuthorizationMetadata {
|
||||
authorization_endpoint: "http://localhost/authorize".to_string(),
|
||||
token_endpoint: "http://localhost/token".to_string(),
|
||||
additional_fields,
|
||||
..Default::default()
|
||||
};
|
||||
let mut mgr = manager_with_metadata(Some(meta)).await;
|
||||
let config = super::ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "id".to_string(),
|
||||
client_secret: "secret".to_string(),
|
||||
scopes: vec![],
|
||||
resource: None,
|
||||
};
|
||||
mgr.configure_client_credentials(&config).unwrap();
|
||||
let oauth_client = mgr.oauth_client.as_ref().unwrap();
|
||||
assert!(
|
||||
!matches!(oauth_client.auth_type(), AuthType::RequestBody),
|
||||
"expected HTTP Basic auth when server only supports client_secret_basic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_credentials_config_returns_correct_accessor_values() {
|
||||
let config = super::ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "test-id".to_string(),
|
||||
client_secret: "test-secret".to_string(),
|
||||
scopes: vec!["scope1".to_string(), "scope2".to_string()],
|
||||
resource: Some("https://example.com".to_string()),
|
||||
};
|
||||
assert_eq!(config.client_id(), "test-id");
|
||||
assert_eq!(config.scopes(), &["scope1", "scope2"]);
|
||||
assert_eq!(config.resource(), Some("https://example.com"));
|
||||
assert_eq!(config.auth_method(), "client_secret_post");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_constant_matches_spec() {
|
||||
assert_eq!(
|
||||
super::EXTENSION_OAUTH_CLIENT_CREDENTIALS,
|
||||
"io.modelcontextprotocol/oauth-client-credentials"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
197
crates/rmcp/tests/test_client_credentials.rs
Normal file
197
crates/rmcp/tests/test_client_credentials.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
use std::{convert::Infallible, net::SocketAddr};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
http::{Request, Response, StatusCode},
|
||||
routing::{get, post},
|
||||
};
|
||||
use rmcp::transport::auth::{ClientCredentialsConfig, OAuthState};
|
||||
|
||||
fn json_response(body: serde_json::Value) -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_string(&body).unwrap()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn json_error(status: StatusCode, body: serde_json::Value) -> Response<Body> {
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(serde_json::to_string(&body).unwrap()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn resource_metadata_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
|
||||
let host = req.headers().get("host").unwrap().to_str().unwrap();
|
||||
let base_url = format!("http://{}", host);
|
||||
Ok(json_response(serde_json::json!({
|
||||
"resource": base_url,
|
||||
"authorization_servers": [base_url],
|
||||
"scopes_supported": ["read", "write"]
|
||||
})))
|
||||
}
|
||||
|
||||
async fn auth_server_metadata_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
|
||||
let host = req.headers().get("host").unwrap().to_str().unwrap();
|
||||
let base_url = format!("http://{}", host);
|
||||
Ok(json_response(serde_json::json!({
|
||||
"issuer": base_url,
|
||||
"authorization_endpoint": format!("{}/authorize", base_url),
|
||||
"token_endpoint": format!("{}/token", base_url),
|
||||
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
|
||||
"grant_types_supported": ["client_credentials"],
|
||||
"scopes_supported": ["read", "write"]
|
||||
})))
|
||||
}
|
||||
|
||||
async fn token_handler(req: Request<Body>) -> Result<Response<Body>, Infallible> {
|
||||
let body_bytes = axum::body::to_bytes(req.into_body(), 1024 * 64)
|
||||
.await
|
||||
.unwrap();
|
||||
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
|
||||
|
||||
// Parse form-urlencoded body
|
||||
let params: Vec<(String, String)> = url::form_urlencoded::parse(body_str.as_bytes())
|
||||
.into_owned()
|
||||
.collect();
|
||||
|
||||
let get_param = |key: &str| -> Option<String> {
|
||||
params
|
||||
.iter()
|
||||
.find(|(k, _)| k == key)
|
||||
.map(|(_, v)| v.clone())
|
||||
};
|
||||
|
||||
let grant_type = get_param("grant_type").unwrap_or_default();
|
||||
if grant_type != "client_credentials" {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
serde_json::json!({
|
||||
"error": "unsupported_grant_type",
|
||||
"error_description": "Only client_credentials grant type is supported"
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let client_id = get_param("client_id").unwrap_or_default();
|
||||
if client_id != "test-m2m-client" {
|
||||
return Ok(json_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
serde_json::json!({
|
||||
"error": "invalid_client",
|
||||
"error_description": "Unknown client_id"
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let client_secret = get_param("client_secret").unwrap_or_default();
|
||||
if client_secret != "test-m2m-secret" {
|
||||
return Ok(json_error(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
serde_json::json!({
|
||||
"error": "invalid_client",
|
||||
"error_description": "Invalid client_secret"
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let scope = get_param("scope").unwrap_or_default();
|
||||
|
||||
let mut response = serde_json::json!({
|
||||
"access_token": "m2m-access-token-12345",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600
|
||||
});
|
||||
|
||||
if !scope.is_empty() {
|
||||
response["scope"] = serde_json::Value::String(scope);
|
||||
}
|
||||
|
||||
Ok(json_response(response))
|
||||
}
|
||||
|
||||
async fn start_mock_server() -> (String, SocketAddr) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let base_url = format!("http://{}", addr);
|
||||
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
get(resource_metadata_handler),
|
||||
)
|
||||
.route(
|
||||
"/.well-known/oauth-authorization-server",
|
||||
get(auth_server_metadata_handler),
|
||||
)
|
||||
.route("/token", post(token_handler));
|
||||
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
|
||||
(base_url, addr)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_credentials_flow_client_secret() {
|
||||
let (base_url, _addr) = start_mock_server().await;
|
||||
|
||||
let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap();
|
||||
|
||||
let config = ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "test-m2m-client".to_string(),
|
||||
client_secret: "test-m2m-secret".to_string(),
|
||||
scopes: vec!["read".to_string(), "write".to_string()],
|
||||
resource: Some(base_url.clone()),
|
||||
};
|
||||
|
||||
oauth_state
|
||||
.authenticate_client_credentials(config)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let manager = oauth_state
|
||||
.into_authorization_manager()
|
||||
.expect("Should be in Authorized state");
|
||||
|
||||
let token = manager.get_access_token().await.unwrap();
|
||||
assert_eq!(token, "m2m-access-token-12345");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_credentials_invalid_secret() {
|
||||
let (base_url, _addr) = start_mock_server().await;
|
||||
|
||||
let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap();
|
||||
|
||||
let config = ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "test-m2m-client".to_string(),
|
||||
client_secret: "wrong-secret".to_string(),
|
||||
scopes: vec![],
|
||||
resource: Some(base_url.clone()),
|
||||
};
|
||||
|
||||
let result = oauth_state.authenticate_client_credentials(config).await;
|
||||
assert!(result.is_err(), "Should fail with invalid credentials");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_client_credentials_invalid_client_id() {
|
||||
let (base_url, _addr) = start_mock_server().await;
|
||||
|
||||
let mut oauth_state = OAuthState::new(&base_url, None).await.unwrap();
|
||||
|
||||
let config = ClientCredentialsConfig::ClientSecret {
|
||||
client_id: "unknown-client".to_string(),
|
||||
client_secret: "test-m2m-secret".to_string(),
|
||||
scopes: vec![],
|
||||
resource: Some(base_url.clone()),
|
||||
};
|
||||
|
||||
let result = oauth_state.authenticate_client_credentials(config).await;
|
||||
assert!(result.is_err(), "Should fail with unknown client_id");
|
||||
}
|
||||
|
|
@ -57,3 +57,7 @@ path = "src/sampling_stdio.rs"
|
|||
[[example]]
|
||||
name = "clients_progress_client"
|
||||
path = "src/progress_client.rs"
|
||||
|
||||
[[example]]
|
||||
name = "clients_client_credentials"
|
||||
path = "src/auth/client_credentials.rs"
|
||||
|
|
|
|||
97
examples/clients/src/auth/client_credentials.rs
Normal file
97
examples/clients/src/auth/client_credentials.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
use std::env;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rmcp::{
|
||||
ServiceExt,
|
||||
model::ClientInfo,
|
||||
transport::{
|
||||
StreamableHttpClientTransport,
|
||||
auth::{AuthClient, ClientCredentialsConfig, OAuthState},
|
||||
streamable_http_client::StreamableHttpClientTransportConfig,
|
||||
},
|
||||
};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
/// Example: OAuth 2.0 Client Credentials flow (SEP-1046)
|
||||
///
|
||||
/// Usage:
|
||||
/// cargo run -p mcp-client-examples --example clients_client_credentials -- <server_url> <client_id> <client_secret>
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "info".to_string().into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let server_url = args
|
||||
.get(1)
|
||||
.context("Usage: <server_url> <client_id> <client_secret>")?
|
||||
.clone();
|
||||
let client_id = args
|
||||
.get(2)
|
||||
.context("Usage: <server_url> <client_id> <client_secret>")?
|
||||
.clone();
|
||||
let client_secret = args
|
||||
.get(3)
|
||||
.context("Usage: <server_url> <client_id> <client_secret>")?
|
||||
.clone();
|
||||
|
||||
tracing::info!("Connecting to MCP server at: {}", server_url);
|
||||
tracing::info!("Using client_id: {}", client_id);
|
||||
|
||||
// Initialize OAuth state and authenticate with client credentials
|
||||
let mut oauth_state = OAuthState::new(&server_url, None)
|
||||
.await
|
||||
.context("Failed to initialize OAuth state")?;
|
||||
|
||||
let config = ClientCredentialsConfig::ClientSecret {
|
||||
client_id,
|
||||
client_secret,
|
||||
scopes: vec![],
|
||||
resource: Some(server_url.clone()),
|
||||
};
|
||||
|
||||
oauth_state
|
||||
.authenticate_client_credentials(config)
|
||||
.await
|
||||
.context("Client credentials authentication failed")?;
|
||||
|
||||
tracing::info!("Successfully authenticated with client credentials");
|
||||
|
||||
// Create authorized transport
|
||||
let manager = oauth_state
|
||||
.into_authorization_manager()
|
||||
.context("Failed to get authorization manager")?;
|
||||
let client = AuthClient::new(reqwest::Client::default(), manager);
|
||||
let transport = StreamableHttpClientTransport::with_client(
|
||||
client,
|
||||
StreamableHttpClientTransportConfig::with_uri(server_url.as_str()),
|
||||
);
|
||||
|
||||
// Connect to MCP server and list tools
|
||||
let client_service = ClientInfo::default();
|
||||
let client = client_service.serve(transport).await?;
|
||||
tracing::info!("Connected to MCP server");
|
||||
|
||||
match client.peer().list_all_tools().await {
|
||||
Ok(tools) => {
|
||||
println!("Available tools ({}):", tools.len());
|
||||
for tool in tools {
|
||||
println!(
|
||||
" - {} ({})",
|
||||
tool.name,
|
||||
tool.description.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to list tools: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Reference in a new issue