use std::sync::Arc;
use axum::http::HeaderMap;
use cheers_core::{McpClaims, Scope};
use cheers_server::PasetoV4PublicVerifier;
use cheers_verify::KeySetVerifier;
use crate::error::RouteError;
use crate::me::bearer_from_headers;
#[derive(Clone)]
pub struct McpAuthState {
pub verifier: Arc<KeySetVerifier>,
pub expected_iss: String,
pub expected_aud: String,
}
impl McpAuthState {
pub fn new(
verifier: PasetoV4PublicVerifier,
kid: impl Into<String>,
expected_iss: impl Into<String>,
expected_aud: impl Into<String>,
) -> Self {
let expected_iss = expected_iss.into();
let key: [u8; 32] = verifier
.public_key()
.as_bytes()
.try_into()
.expect("Ed25519 public key is 32 bytes");
let keys = KeySetVerifier::from_issuer_key(kid, &key, expected_iss.clone());
Self::from_key_set(keys, expected_iss, expected_aud)
}
pub fn from_key_set(
verifier: KeySetVerifier,
expected_iss: impl Into<String>,
expected_aud: impl Into<String>,
) -> Self {
Self {
verifier: Arc::new(verifier),
expected_iss: expected_iss.into(),
expected_aud: expected_aud.into(),
}
}
}
impl std::fmt::Debug for McpAuthState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpAuthState")
.field("expected_iss", &self.expected_iss)
.field("expected_aud", &self.expected_aud)
.finish_non_exhaustive()
}
}
pub async fn authenticate_mcp(
headers: &HeaderMap,
state: &McpAuthState,
now: i64,
) -> Result<McpClaims, RouteError> {
verify_mcp_bearer(
headers,
&state.verifier,
&state.expected_iss,
Some(state.expected_aud.as_str()),
now,
)
.await
}
pub async fn verify_mcp_bearer(
headers: &HeaderMap,
verifier: &KeySetVerifier,
expected_iss: &str,
expected_aud: Option<&str>,
now: i64,
) -> Result<McpClaims, RouteError> {
let token = bearer_from_headers(headers)?;
verifier
.verify_mcp(token, now, expected_iss, expected_aud)
.await
.map_err(|_| RouteError::Unauthorized)
}
pub trait McpClaimsExt {
fn require_scope(&self, required: Scope) -> Result<(), RouteError>;
}
impl McpClaimsExt for McpClaims {
fn require_scope(&self, required: Scope) -> Result<(), RouteError> {
if self.scope.contains(&required) {
Ok(())
} else {
Err(RouteError::InsufficientScope { required })
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use cheers_core::yah_scopes;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::IntoResponse;
use cheers_core::{AuthStrength, McpClaims, PrincipalId};
use cheers_server::PasetoV4SecretMinter;
fn authenticate_mcp(h: &HeaderMap, s: &McpAuthState, now: i64) -> Result<McpClaims, RouteError> {
tokio::runtime::Builder::new_current_thread()
.build()
.unwrap()
.block_on(super::authenticate_mcp(h, s, now))
}
const TEST_KID: &str = "mcp-test-kid-1";
const TEST_ISS: &str = "https://cheers.example";
const TEST_AUD: &str = "https://kamaji.example";
fn rig() -> (PasetoV4SecretMinter, McpAuthState) {
let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
let state = McpAuthState::new(verifier, TEST_KID, TEST_ISS, TEST_AUD);
(minter, state)
}
fn bearer(token: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(
header::AUTHORIZATION,
format!("Bearer {token}").parse().unwrap(),
);
h
}
fn sample_claims(jti: &str) -> McpClaims {
McpClaims::new(
TEST_ISS,
TEST_AUD,
PrincipalId::user("alice"),
1_000,
1_600,
jti,
vec![yah_scopes::CLOUD_DEPLOY, yah_scopes::CLOUD_READ],
)
.with_auth_strength(AuthStrength::UserFresh)
}
#[test]
fn authenticate_mcp_verifies_valid_token_and_returns_claims() {
let (minter, state) = rig();
let claims = sample_claims("jti-1");
let token = minter.mint_mcp(&claims, TEST_KID).unwrap();
let headers = bearer(&token);
let back = authenticate_mcp(&headers, &state, 1_100).unwrap();
assert_eq!(back, claims);
}
#[test]
fn authenticate_mcp_rejects_expired_as_unauthorized() {
let (minter, state) = rig();
let claims = sample_claims("jti-exp");
let token = minter.mint_mcp(&claims, TEST_KID).unwrap();
let err = authenticate_mcp(&bearer(&token), &state, 1_600).unwrap_err();
assert!(matches!(err, RouteError::Unauthorized));
}
#[test]
fn authenticate_mcp_rejects_bad_signature_as_unauthorized() {
let (minter, _state) = rig();
let (_other_minter, other_state) = rig();
let token = minter.mint_mcp(&sample_claims("jti-bad"), TEST_KID).unwrap();
let err = authenticate_mcp(&bearer(&token), &other_state, 1_100).unwrap_err();
assert!(matches!(err, RouteError::Unauthorized));
}
#[test]
fn authenticate_mcp_rejects_malformed_bearer() {
let (_minter, state) = rig();
let mut headers = HeaderMap::new();
headers.insert(header::AUTHORIZATION, "Basic xyz".parse().unwrap());
let err = authenticate_mcp(&headers, &state, 1_100).unwrap_err();
assert!(matches!(err, RouteError::MalformedBearer));
}
#[test]
fn authenticate_mcp_rejects_missing_bearer() {
let (_minter, state) = rig();
let err = authenticate_mcp(&HeaderMap::new(), &state, 1_100).unwrap_err();
assert!(matches!(err, RouteError::MissingBearer));
}
#[test]
fn authenticate_mcp_rejects_session_token_as_unauthorized() {
use cheers_core::{Claims, DeviceBinding, DeviceId, TokenMinter, UserId};
let (minter, state) = rig();
let session_claims = Claims::new(
UserId::new("alice"),
DeviceId::new("dev"),
DeviceBinding::Passkey,
1_000,
1_600,
);
let session_token = minter.mint(&session_claims).unwrap();
let err = authenticate_mcp(&bearer(&session_token), &state, 1_100).unwrap_err();
assert!(matches!(err, RouteError::Unauthorized));
}
#[test]
fn authenticate_mcp_rejects_wrong_kid_as_unauthorized() {
let (minter, state) = rig();
let token = minter
.mint_mcp(&sample_claims("jti-wrong-kid"), "some-other-kid")
.unwrap();
let err = authenticate_mcp(&bearer(&token), &state, 1_100).unwrap_err();
assert!(matches!(err, RouteError::Unauthorized));
}
#[test]
fn authenticate_mcp_rejects_wrong_iss_as_unauthorized() {
let (minter, state) = rig();
let claims = McpClaims::new(
"https://not-cheers.example",
TEST_AUD,
PrincipalId::user("alice"),
1_000,
1_600,
"jti-wrong-iss",
vec![yah_scopes::CLOUD_DEPLOY],
)
.with_auth_strength(AuthStrength::UserFresh);
let token = minter.mint_mcp(&claims, TEST_KID).unwrap();
let err = authenticate_mcp(&bearer(&token), &state, 1_100).unwrap_err();
assert!(matches!(err, RouteError::Unauthorized));
}
#[test]
fn authenticate_mcp_rejects_wrong_aud_as_unauthorized() {
let (minter, state) = rig();
let claims = McpClaims::new(
TEST_ISS,
"https://unrelated-resource.example",
PrincipalId::user("alice"),
1_000,
1_600,
"jti-wrong-aud",
vec![yah_scopes::CLOUD_DEPLOY],
)
.with_auth_strength(AuthStrength::UserFresh);
let token = minter.mint_mcp(&claims, TEST_KID).unwrap();
let err = authenticate_mcp(&bearer(&token), &state, 1_100).unwrap_err();
assert!(matches!(err, RouteError::Unauthorized));
}
#[test]
fn require_scope_accepts_held_scope() {
let claims = sample_claims("jti");
claims.require_scope(yah_scopes::CLOUD_DEPLOY).unwrap();
claims.require_scope(yah_scopes::CLOUD_READ).unwrap();
}
#[test]
fn require_scope_rejects_missing_scope() {
let claims = sample_claims("jti");
let err = claims.require_scope(yah_scopes::AUDIT_WRITE).unwrap_err();
match err {
RouteError::InsufficientScope { required } => {
assert_eq!(required, yah_scopes::AUDIT_WRITE);
}
other => panic!("expected InsufficientScope, got {other:?}"),
}
}
#[test]
fn insufficient_scope_responds_403_with_stable_code() {
let err = RouteError::InsufficientScope {
required: yah_scopes::AUDIT_WRITE,
};
let (status, code) = err.status_and_code();
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(code, "insufficient_scope");
let resp = err.into_response();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
}