use super::oauth::{UNAUTHORIZED_CODE, authorize_request, is_public_method, load_transport_token};
use super::types::RequestAuthorization;
use do_memory_mcp::jsonrpc::{JsonRpcError, JsonRpcResponse};
use do_memory_mcp::protocol::OAuthConfig;
use do_memory_mcp::server::rate_limiter::{ClientId, OperationType};
use serde_json::{Value, json};
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use tracing::warn;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rejection {
pub code: i32,
pub error: String,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct AuthContext {
config: OAuthConfig,
process_identity: ClientId,
transport_token: Option<String>,
}
impl AuthContext {
pub fn new(
config: OAuthConfig,
process_identity: ClientId,
transport_token: Option<String>,
) -> Self {
Self {
config,
process_identity,
transport_token,
}
}
pub fn from_env(config: OAuthConfig) -> Self {
Self::new(config, ClientId::process(), load_transport_token())
}
pub fn config(&self) -> &OAuthConfig {
&self.config
}
pub fn is_enforced(&self) -> bool {
self.config.is_enforced()
}
pub fn resolve_identity(
&self,
method: &str,
operation: OperationType,
params: Option<&Value>,
) -> Result<ClientId, Rejection> {
if self.is_enforced() && is_public_method(method) {
return Ok(self.process_identity.clone());
}
match authorize_request(
params,
operation,
&self.config,
self.transport_token.as_deref(),
) {
RequestAuthorization::Unauthenticated => Ok(self.process_identity.clone()),
RequestAuthorization::Authenticated(principal) => {
Ok(subject_identity(&principal.subject))
}
RequestAuthorization::Rejected {
code,
error,
description,
} => Err(Rejection {
code,
error,
description,
}),
}
}
}
pub fn subject_identity(subject: &str) -> ClientId {
let mut hasher = DefaultHasher::new();
subject.hash(&mut hasher);
ClientId::Id(format!("subject:{:016x}", hasher.finish()))
}
pub fn rejection_response(id: Option<Value>, rejection: &Rejection) -> JsonRpcResponse {
warn!(
"Request rejected before dispatch: {} ({})",
rejection.error, rejection.description
);
let message = if rejection.code == UNAUTHORIZED_CODE {
"Unauthorized"
} else {
"Insufficient scope"
};
JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError {
code: rejection.code,
message: message.to_string(),
data: Some(json!({
"error": rejection.error,
"description": rejection.description,
})),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "oauth")]
const SECRET: &str = "auth-context-test-secret";
#[cfg(feature = "oauth")]
const ISSUER: &str = "https://auth.example.com";
#[cfg(feature = "oauth")]
const AUDIENCE: &str = "mcp-server";
fn disabled_config() -> OAuthConfig {
OAuthConfig::default()
}
fn unenforceable_config() -> OAuthConfig {
OAuthConfig {
enabled: true,
token_secret: None,
..OAuthConfig::default()
}
}
#[cfg(feature = "oauth")]
fn enforced_config() -> OAuthConfig {
OAuthConfig {
enabled: true,
issuer: Some(ISSUER.to_string()),
audience: Some(AUDIENCE.to_string()),
token_secret: Some(SECRET.to_string()),
..OAuthConfig::default()
}
}
#[cfg(feature = "oauth")]
fn signed_token(scope: &str, subject: &str) -> anyhow::Result<String> {
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
use serde::Serialize;
#[derive(Debug, Serialize)]
struct Claims {
iss: String,
aud: String,
exp: u64,
sub: String,
scope: String,
}
Ok(encode(
&Header::new(Algorithm::HS256),
&Claims {
iss: ISSUER.to_string(),
aud: AUDIENCE.to_string(),
exp: jsonwebtoken::get_current_timestamp() + 600,
sub: subject.to_string(),
scope: scope.to_string(),
},
&EncodingKey::from_secret(SECRET.as_bytes()),
)?)
}
#[test]
fn test_subject_identity_is_stable_and_opaque() {
let alice = subject_identity("alice@example.com");
assert_eq!(alice, subject_identity("alice@example.com"));
assert_ne!(alice, subject_identity("bob@example.com"));
assert!(
!format!("{alice}").contains("alice@example.com"),
"bucket keys must not persist the raw subject"
);
}
#[test]
fn test_auth_context_reports_enforcement() {
let disabled = AuthContext::new(disabled_config(), ClientId::process(), None);
assert!(!disabled.is_enforced());
assert!(!disabled.config().enabled);
let misconfigured = AuthContext::new(
unenforceable_config(),
ClientId::process(),
Some("unused".to_string()),
);
assert!(!misconfigured.is_enforced());
assert!(misconfigured.config().enabled);
}
#[test]
fn test_auth_context_from_env_uses_process_identity() {
let context = AuthContext::from_env(disabled_config());
assert!(!context.is_enforced());
let identity = context
.resolve_identity("tools/list", OperationType::Write, None)
.expect("disabled OAuth must not reject requests");
assert_eq!(identity, ClientId::process());
}
#[test]
fn test_resolve_identity_disabled_uses_process_identity() {
let context = AuthContext::new(disabled_config(), ClientId::process(), None);
for method in ["tools/list", "tools/call", "initialize"] {
let identity = context
.resolve_identity(method, OperationType::Write, None)
.expect("disabled OAuth must not reject requests");
assert_eq!(identity, ClientId::process());
}
}
#[test]
fn test_resolve_identity_unenforceable_config_fails_closed() {
let context = AuthContext::new(unenforceable_config(), ClientId::process(), None);
for method in ["initialize", "tools/list", "tools/call"] {
let rejection = context
.resolve_identity(method, OperationType::Write, None)
.expect_err("unenforceable configuration must fail closed");
assert_eq!(rejection.code, UNAUTHORIZED_CODE);
assert_eq!(rejection.error, "invalid_config");
assert!(
rejection.description.contains("cannot be enforced"),
"unexpected diagnostic: {rejection:?}"
);
}
}
#[test]
fn test_rejection_response_codes_and_messages() {
let unauthorized = Rejection {
code: UNAUTHORIZED_CODE,
error: "unauthorized".to_string(),
description: "missing bearer token".to_string(),
};
let response = rejection_response(Some(json!(7)), &unauthorized);
assert_eq!(response.id, Some(json!(7)));
assert!(response.result.is_none());
let error = response.error.expect("rejection must carry an error");
assert_eq!(error.code, UNAUTHORIZED_CODE);
assert_eq!(error.message, "Unauthorized");
assert_eq!(
error.data,
Some(json!({"error": "unauthorized", "description": "missing bearer token"}))
);
let scope = Rejection {
code: -32003,
error: "insufficient_scope".to_string(),
description: "token is missing the required scope 'mcp:write'".to_string(),
};
let response = rejection_response(None, &scope);
assert!(response.id.is_none());
let error = response.error.expect("rejection must carry an error");
assert_eq!(error.code, -32003);
assert_eq!(error.message, "Insufficient scope");
}
#[test]
fn test_resolve_identity_ignores_request_identity_fields() {
let params = json!({"_meta": {"client_id": "spoofed"}, "client_id": "spoofed"});
let context = AuthContext::new(disabled_config(), ClientId::process(), None);
let identity = context
.resolve_identity("tools/call", OperationType::Write, Some(¶ms))
.expect("disabled OAuth must not reject requests");
assert_eq!(identity, ClientId::process());
}
#[cfg(feature = "oauth")]
#[test]
fn test_resolve_identity_authenticated_uses_subject() -> anyhow::Result<()> {
let token = signed_token("mcp:read", "user-42")?;
let params = json!({"_meta": {"authorization": format!("Bearer {token}")}});
let context = AuthContext::new(enforced_config(), ClientId::process(), None);
let identity = context
.resolve_identity("tools/list", OperationType::Read, Some(¶ms))
.map_err(|rejection| anyhow::anyhow!("{rejection:?}"))?;
assert_eq!(identity, subject_identity("user-42"));
assert_ne!(identity, ClientId::process());
Ok(())
}
#[cfg(feature = "oauth")]
#[test]
fn test_resolve_identity_uses_transport_credential() -> anyhow::Result<()> {
let token = signed_token("mcp:write", "user-43")?;
let context = AuthContext::new(enforced_config(), ClientId::process(), Some(token.clone()));
let identity = context
.resolve_identity("tools/call", OperationType::Write, None)
.map_err(|rejection| anyhow::anyhow!("{rejection:?}"))?;
assert_eq!(identity, subject_identity("user-43"));
Ok(())
}
#[cfg(feature = "oauth")]
#[test]
fn test_resolve_identity_enforced_rules() -> anyhow::Result<()> {
let context = AuthContext::new(enforced_config(), ClientId::process(), None);
assert_eq!(
context
.resolve_identity("initialize", OperationType::Read, None)
.map_err(|rejection| anyhow::anyhow!("{rejection:?}"))?,
ClientId::process()
);
let rejection = context
.resolve_identity("tools/list", OperationType::Read, None)
.expect_err("protected method must require a token");
assert_eq!(rejection.code, UNAUTHORIZED_CODE);
assert_eq!(rejection.error, "unauthorized");
let params = json!({"_meta": {"authorization": "Bearer not-a-jwt"}});
let rejection = context
.resolve_identity("tools/list", OperationType::Read, Some(¶ms))
.expect_err("invalid token must be rejected");
assert_eq!(rejection.error, "invalid_token");
assert!(!rejection.description.contains("not-a-jwt"));
let token = signed_token("mcp:read", "user-44")?;
let params = json!({"_meta": {"authorization": format!("Bearer {token}")}});
let rejection = context
.resolve_identity("tools/call", OperationType::Write, Some(¶ms))
.expect_err("read scope must not authorize a write");
assert_eq!(rejection.code, -32003);
assert_eq!(rejection.error, "insufficient_scope");
Ok(())
}
}