use axum::{
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use jsonwebtoken::{DecodingKey, Validation, decode};
use super::models::AccessTokenClaims;
#[derive(Debug, Clone)]
pub enum BearerError {
MissingToken,
InvalidToken(String),
ExpiredToken,
InsufficientScope(String),
InvalidAudience(String),
}
impl BearerError {
pub fn error_code(&self) -> &'static str {
match self {
BearerError::MissingToken => "invalid_request",
BearerError::InvalidToken(_) => "invalid_token",
BearerError::ExpiredToken => "invalid_token",
BearerError::InsufficientScope(_) => "insufficient_scope",
BearerError::InvalidAudience(_) => "invalid_token",
}
}
pub fn error_description(&self) -> String {
match self {
BearerError::MissingToken => "No access token provided".to_string(),
BearerError::InvalidToken(msg) => msg.clone(),
BearerError::ExpiredToken => "Access token has expired".to_string(),
BearerError::InsufficientScope(scope) => {
format!("Insufficient scope, required: {}", scope)
}
BearerError::InvalidAudience(aud) => {
format!("Token not intended for this resource: {}", aud)
}
}
}
}
pub struct WwwAuthenticate {
realm: String,
error: Option<BearerError>,
resource_metadata_url: Option<String>,
}
impl WwwAuthenticate {
pub fn new(realm: impl Into<String>) -> Self {
Self {
realm: realm.into(),
error: None,
resource_metadata_url: None,
}
}
pub fn with_error(mut self, error: BearerError) -> Self {
self.error = Some(error);
self
}
pub fn with_resource_metadata(mut self, url: impl Into<String>) -> Self {
self.resource_metadata_url = Some(url.into());
self
}
pub fn to_header_value(&self) -> HeaderValue {
let mut parts = vec![format!("Bearer realm=\"{}\"", self.realm)];
if let Some(ref error) = self.error {
parts.push(format!("error=\"{}\"", error.error_code()));
parts.push(format!(
"error_description=\"{}\"",
error.error_description()
));
}
if let Some(ref url) = self.resource_metadata_url {
parts.push(format!("resource_metadata=\"{}\"", url));
}
HeaderValue::from_str(&parts.join(", "))
.unwrap_or_else(|_| HeaderValue::from_static("Bearer realm=\"mcp\""))
}
pub fn into_response(self) -> Response {
let mut headers = HeaderMap::new();
headers.insert(header::WWW_AUTHENTICATE, self.to_header_value());
let body = if let Some(ref error) = self.error {
serde_json::json!({
"error": error.error_code(),
"error_description": error.error_description()
})
.to_string()
} else {
"".to_string()
};
(StatusCode::UNAUTHORIZED, headers, body).into_response()
}
}
#[derive(Debug, Clone)]
pub struct BearerToken {
pub claims: AccessTokenClaims,
pub raw_token: String,
}
#[derive(Debug, Clone)]
pub struct BearerTokenConfig {
pub jwt_secret: String,
pub expected_audience: Option<String>,
pub realm: String,
pub resource_metadata_url: Option<String>,
}
impl Default for BearerTokenConfig {
fn default() -> Self {
let base_url =
std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
Self {
jwt_secret: std::env::var("JWT_SECRET")
.unwrap_or_else(|_| "REPLACE_THIS_WITH_SECURE_SECRET".to_string()),
expected_audience: Some(base_url.clone()),
realm: "mcp".to_string(),
resource_metadata_url: Some(format!(
"{}/.well-known/oauth-protected-resource",
base_url
)),
}
}
}
pub fn validate_bearer_token(
auth_header: &str,
config: &BearerTokenConfig,
) -> Result<BearerToken, BearerError> {
let token = auth_header
.strip_prefix("Bearer ")
.or_else(|| auth_header.strip_prefix("bearer "))
.ok_or_else(|| BearerError::InvalidToken("Invalid authorization header format".into()))?;
let mut validation = Validation::default();
if let Some(ref expected_aud) = config.expected_audience {
validation.set_audience(&[expected_aud]);
}
let token_data = decode::<AccessTokenClaims>(
token,
&DecodingKey::from_secret(config.jwt_secret.as_bytes()),
&validation,
)
.map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => BearerError::ExpiredToken,
jsonwebtoken::errors::ErrorKind::InvalidAudience => {
BearerError::InvalidAudience(config.expected_audience.clone().unwrap_or_default())
}
_ => BearerError::InvalidToken(format!("Token validation failed: {}", e)),
})?;
Ok(BearerToken {
claims: token_data.claims,
raw_token: token.to_string(),
})
}
pub fn unauthorized_response(error: BearerError, config: &BearerTokenConfig) -> Response {
let mut www_auth = WwwAuthenticate::new(&config.realm).with_error(error);
if let Some(ref url) = config.resource_metadata_url {
www_auth = www_auth.with_resource_metadata(url);
}
www_auth.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{EncodingKey, Header, encode};
fn create_test_token(claims: &AccessTokenClaims, secret: &str) -> String {
encode(
&Header::default(),
claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap()
}
fn test_config() -> BearerTokenConfig {
BearerTokenConfig {
jwt_secret: "test_secret_key_12345".to_string(),
expected_audience: Some("https://api.example.com".to_string()),
realm: "mcp".to_string(),
resource_metadata_url: Some(
"https://api.example.com/.well-known/oauth-protected-resource".to_string(),
),
}
}
fn valid_claims() -> AccessTokenClaims {
AccessTokenClaims {
iss: "https://auth.example.com".to_string(),
sub: "user123".to_string(),
aud: Some("https://api.example.com".to_string()),
exp: (chrono::Utc::now() + chrono::Duration::hours(1)).timestamp(),
iat: chrono::Utc::now().timestamp(),
scope: "read write".to_string(),
client_id: "client_abc".to_string(),
}
}
#[test]
fn test_www_authenticate_header_basic() {
let header = WwwAuthenticate::new("mcp").to_header_value();
assert!(header.to_str().unwrap().contains("Bearer realm=\"mcp\""));
}
#[test]
fn test_www_authenticate_header_with_error() {
let header = WwwAuthenticate::new("mcp")
.with_error(BearerError::ExpiredToken)
.to_header_value();
let header_str = header.to_str().unwrap();
assert!(header_str.contains("error=\"invalid_token\""));
assert!(header_str.contains("error_description="));
}
#[test]
fn test_www_authenticate_header_with_resource_metadata() {
let header = WwwAuthenticate::new("mcp")
.with_resource_metadata("https://example.com/.well-known/oauth-protected-resource")
.to_header_value();
let header_str = header.to_str().unwrap();
assert!(header_str.contains("resource_metadata="));
}
#[test]
fn test_www_authenticate_into_response() {
let response = WwwAuthenticate::new("mcp")
.with_error(BearerError::ExpiredToken)
.into_response();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn test_www_authenticate_into_response_no_error() {
let response = WwwAuthenticate::new("mcp").into_response();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn test_bearer_error_codes() {
assert_eq!(BearerError::MissingToken.error_code(), "invalid_request");
assert_eq!(
BearerError::InvalidToken("test".into()).error_code(),
"invalid_token"
);
assert_eq!(BearerError::ExpiredToken.error_code(), "invalid_token");
assert_eq!(
BearerError::InsufficientScope("test".into()).error_code(),
"insufficient_scope"
);
assert_eq!(
BearerError::InvalidAudience("test".into()).error_code(),
"invalid_token"
);
}
#[test]
fn test_bearer_error_descriptions() {
assert!(
BearerError::MissingToken
.error_description()
.contains("No access token")
);
assert!(
BearerError::InvalidToken("bad token".into())
.error_description()
.contains("bad token")
);
assert!(
BearerError::ExpiredToken
.error_description()
.contains("expired")
);
assert!(
BearerError::InsufficientScope("admin".into())
.error_description()
.contains("admin")
);
assert!(
BearerError::InvalidAudience("wrong-aud".into())
.error_description()
.contains("wrong-aud")
);
}
#[test]
fn test_validate_bearer_token_success() {
let config = test_config();
let claims = valid_claims();
let token = create_test_token(&claims, &config.jwt_secret);
let auth_header = format!("Bearer {}", token);
let result = validate_bearer_token(&auth_header, &config);
assert!(result.is_ok());
let bearer = result.unwrap();
assert_eq!(bearer.claims.sub, "user123");
assert_eq!(bearer.raw_token, token);
}
#[test]
fn test_validate_bearer_token_lowercase() {
let config = test_config();
let claims = valid_claims();
let token = create_test_token(&claims, &config.jwt_secret);
let auth_header = format!("bearer {}", token);
let result = validate_bearer_token(&auth_header, &config);
assert!(result.is_ok());
}
#[test]
fn test_validate_bearer_token_invalid_format() {
let config = test_config();
let result = validate_bearer_token("Basic dXNlcjpwYXNz", &config);
assert!(matches!(result, Err(BearerError::InvalidToken(_))));
}
#[test]
fn test_validate_bearer_token_invalid_jwt() {
let config = test_config();
let result = validate_bearer_token("Bearer invalid.jwt.token", &config);
assert!(matches!(result, Err(BearerError::InvalidToken(_))));
}
#[test]
fn test_validate_bearer_token_wrong_secret() {
let config = test_config();
let claims = valid_claims();
let token = create_test_token(&claims, "wrong_secret");
let auth_header = format!("Bearer {}", token);
let result = validate_bearer_token(&auth_header, &config);
assert!(matches!(result, Err(BearerError::InvalidToken(_))));
}
#[test]
fn test_validate_bearer_token_expired() {
let config = test_config();
let mut claims = valid_claims();
claims.exp = (chrono::Utc::now() - chrono::Duration::hours(1)).timestamp();
let token = create_test_token(&claims, &config.jwt_secret);
let auth_header = format!("Bearer {}", token);
let result = validate_bearer_token(&auth_header, &config);
assert!(matches!(result, Err(BearerError::ExpiredToken)));
}
#[test]
fn test_validate_bearer_token_wrong_audience() {
let config = test_config();
let mut claims = valid_claims();
claims.aud = Some("https://wrong-audience.com".to_string());
let token = create_test_token(&claims, &config.jwt_secret);
let auth_header = format!("Bearer {}", token);
let result = validate_bearer_token(&auth_header, &config);
assert!(matches!(result, Err(BearerError::InvalidAudience(_))));
}
#[test]
fn test_validate_bearer_token_no_audience_validation() {
let mut config = test_config();
config.expected_audience = None;
let mut claims = valid_claims();
claims.aud = None; let token = create_test_token(&claims, &config.jwt_secret);
let auth_header = format!("Bearer {}", token);
let result = validate_bearer_token(&auth_header, &config);
if result.is_err() {
match result.unwrap_err() {
BearerError::ExpiredToken => panic!("Should not be expired"),
BearerError::InvalidToken(msg) if msg.contains("signature") => {
panic!("Signature should be valid")
}
_ => {} }
}
}
#[test]
fn test_unauthorized_response() {
let config = test_config();
let response = unauthorized_response(BearerError::ExpiredToken, &config);
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn test_unauthorized_response_no_metadata() {
let mut config = test_config();
config.resource_metadata_url = None;
let response = unauthorized_response(BearerError::MissingToken, &config);
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[test]
fn test_bearer_token_config_default() {
let config = BearerTokenConfig::default();
assert_eq!(config.realm, "mcp");
assert!(config.resource_metadata_url.is_some());
}
}