use std::sync::{Arc, OnceLock};
use axum::extract::Request;
use axum::middleware::Next;
use axum::response::Response;
use http::{HeaderValue, StatusCode, header::WWW_AUTHENTICATE};
use rmcp::model::{ErrorCode, ErrorData};
use serde_json::{Value, json};
pub const CREDENTIALS_REJECTED: i32 = -32040;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rejection {
Unauthorized,
Forbidden,
}
impl Rejection {
pub fn from_status(status: u16) -> Option<Self> {
match status {
401 => Some(Self::Unauthorized),
403 => Some(Self::Forbidden),
_ => None,
}
}
pub fn status(self) -> StatusCode {
match self {
Self::Unauthorized => StatusCode::UNAUTHORIZED,
Self::Forbidden => StatusCode::FORBIDDEN,
}
}
fn message(self, upstream: &str) -> String {
match self {
Self::Unauthorized => format!(
"{upstream} rejected the credentials (HTTP 401): sign in again or refresh the token"
),
Self::Forbidden => format!(
"{upstream} refused access with these credentials (HTTP 403): sign in with an \
account that is allowed to do this"
),
}
}
fn data(self, upstream: &str) -> Value {
json!({ "httpStatus": self.status().as_u16(), "upstream": upstream })
}
}
#[derive(Debug, Clone, Default)]
struct RejectionSlot(Arc<OnceLock<Rejection>>);
pub fn credentials_rejected<R>(
ctx: &rmcp::service::RequestContext<R>,
rejection: Rejection,
upstream: &str,
) -> ErrorData
where
R: rmcp::service::ServiceRole,
{
let slot = ctx.extensions.get::<RejectionSlot>().or_else(|| {
ctx.extensions
.get::<http::request::Parts>()
.and_then(|p| p.extensions.get::<RejectionSlot>())
});
if let Some(slot) = slot {
let _ = slot.0.set(rejection);
}
rejection_error(rejection, upstream)
}
pub fn rejection_error(rejection: Rejection, upstream: &str) -> ErrorData {
ErrorData::new(
ErrorCode(CREDENTIALS_REJECTED),
rejection.message(upstream),
Some(rejection.data(upstream)),
)
}
pub fn rejection_envelope(id: Value, rejection: Rejection, upstream: &str) -> Value {
json!({
"jsonrpc": "2.0",
"id": id,
"error": {
"code": CREDENTIALS_REJECTED,
"message": rejection.message(upstream),
"data": rejection.data(upstream),
}
})
}
pub fn rejection_in_envelope(envelope: &Value) -> Option<Rejection> {
let error = envelope.get("error")?;
if error.get("code")?.as_i64()? != i64::from(CREDENTIALS_REJECTED) {
return None;
}
let status = error.pointer("/data/httpStatus")?.as_u64()?;
Rejection::from_status(u16::try_from(status).ok()?)
}
pub(crate) fn apply(rejection: Rejection, response: &mut Response) {
*response.status_mut() = rejection.status();
if rejection == Rejection::Unauthorized {
response.headers_mut().insert(
WWW_AUTHENTICATE,
HeaderValue::from_static(r#"Bearer error="invalid_token""#),
);
}
}
pub async fn credential_rejection_middleware(mut request: Request, next: Next) -> Response {
let slot = RejectionSlot::default();
request.extensions_mut().insert(slot.clone());
let mut response = next.run(request).await;
if let Some(rejection) = slot.0.get().copied()
&& response.status().is_success()
{
apply(rejection, &mut response);
}
response
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_mapping() {
assert_eq!(Rejection::from_status(401), Some(Rejection::Unauthorized));
assert_eq!(Rejection::from_status(403), Some(Rejection::Forbidden));
assert_eq!(Rejection::from_status(404), None);
assert_eq!(Rejection::from_status(500), None);
assert_eq!(Rejection::Unauthorized.status(), StatusCode::UNAUTHORIZED);
assert_eq!(Rejection::Forbidden.status(), StatusCode::FORBIDDEN);
}
#[test]
fn error_is_clear_without_http() {
let e = rejection_error(Rejection::Unauthorized, "mail server");
assert_eq!(e.code, ErrorCode(CREDENTIALS_REJECTED));
assert!(e.message.contains("mail server rejected the credentials"));
assert!(e.message.contains("sign in again"));
assert_eq!(e.data.as_ref().unwrap()["httpStatus"], 401);
}
#[test]
fn envelope_round_trips() {
let env = rejection_envelope(json!(7), Rejection::Forbidden, "wiki");
assert_eq!(env["id"], 7);
assert_eq!(rejection_in_envelope(&env), Some(Rejection::Forbidden));
let other = json!({"jsonrpc":"2.0","id":1,"error":{"code":-32603,"message":"x"}});
assert_eq!(rejection_in_envelope(&other), None);
let ok = json!({"jsonrpc":"2.0","id":1,"result":{}});
assert_eq!(rejection_in_envelope(&ok), None);
}
}