use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::extractors::{Tenant, TenantScope};
use crate::login_throttle::{ClientIp, LoginRefused, LoginScope};
use crate::tenancy::jwt_lifecycle::{JwtIssueError, JwtLifecycle};
use crate::tenancy::{AgentError, TenancyError};
use super::router::AuthedMcpState;
use super::transport::handle_message;
pub const CLAIM_KIND: &str = crate::tenancy::jwt_lifecycle::CLAIM_KIND;
pub const KIND_AGENT: &str = "agent";
pub const CLAIM_TENANT: &str = crate::tenancy::jwt_lifecycle::CLAIM_TENANT;
pub const CLAIM_SKILLS: &str = "skills";
pub const CLAIM_TOOLS: &str = "tools";
pub const CLAIM_UID: &str = "uid";
pub const CLAIM_KEY_PREFIX: &str = "key_prefix";
#[derive(Debug, Clone)]
pub struct McpAgent {
pub agent_id: i64,
pub tenant: String,
pub skills: Vec<String>,
pub tools: Vec<String>,
pub user_id: Option<i64>,
pub jti: String,
pub secret_prefix: String,
}
pub fn issue_agent_token(
jwt: &JwtLifecycle,
agent_id: i64,
tenant: &str,
skills: &[String],
tools: &[String],
user_id: Option<i64>,
secret_prefix: &str,
) -> Result<String, JwtIssueError> {
let mut custom = serde_json::Map::new();
custom.insert(CLAIM_KIND.into(), json!(KIND_AGENT));
custom.insert(CLAIM_KEY_PREFIX.into(), json!(secret_prefix));
custom.insert(CLAIM_TENANT.into(), json!(tenant));
custom.insert(CLAIM_SKILLS.into(), json!(skills));
custom.insert(CLAIM_TOOLS.into(), json!(tools));
if let Some(uid) = user_id {
custom.insert(CLAIM_UID.into(), json!(uid));
}
jwt.issue_access_with(agent_id, custom)
}
#[must_use]
pub async fn verify_agent_token(
jwt: &JwtLifecycle,
token: &str,
expected_tenant: &str,
) -> Option<McpAgent> {
verify_agent_claims(jwt, token, expected_tenant)
.await
.map(|(agent, _)| agent)
}
async fn verify_agent_claims(
jwt: &JwtLifecycle,
token: &str,
expected_tenant: &str,
) -> Option<(McpAgent, i64)> {
let claims = jwt.verify_access(token).await?;
if claims.get_custom::<String>(CLAIM_KIND).as_deref() != Some(KIND_AGENT) {
return None;
}
let tenant = claims.get_custom::<String>(CLAIM_TENANT)?;
if tenant != expected_tenant {
return None;
}
let secret_prefix = claims.get_custom::<String>(CLAIM_KEY_PREFIX)?;
let agent = McpAgent {
agent_id: claims.sub,
tenant,
skills: claims
.get_custom::<Vec<String>>(CLAIM_SKILLS)
.unwrap_or_default(),
tools: claims
.get_custom::<Vec<String>>(CLAIM_TOOLS)
.unwrap_or_default(),
user_id: claims.get_custom::<i64>(CLAIM_UID),
jti: claims.jti,
secret_prefix,
};
Some((agent, claims.exp))
}
#[derive(Deserialize)]
pub(crate) struct AgentTokenInput {
pub name: String,
pub secret: String,
}
#[derive(Serialize)]
pub(crate) struct AgentTokenOutput {
pub access_token: String,
pub token_type: &'static str,
pub expires_in: i64,
}
pub(crate) struct MintedToken {
pub token: String,
pub expires_in: i64,
pub scope: String,
}
pub(crate) enum MintError {
Unauthorized,
Internal,
Refused(LoginRefused),
}
async fn gated<T>(
slug: &str,
ip: &ClientIp,
check: impl std::future::Future<Output = Result<Option<T>, AgentError>>,
) -> Result<Result<Option<T>, AgentError>, LoginRefused> {
let scope = LoginScope::TenantAgent(slug.to_owned());
let attempt = crate::login_throttle::shared()
.begin(&scope, ip, "")
.await?;
let checked = check.await;
match &checked {
Ok(Some(_)) => attempt.succeeded().await,
Ok(None) => attempt.failed().await,
Err(_) => drop(attempt),
}
Ok(checked)
}
pub(crate) async fn mint_agent_jwt(
jwt: &JwtLifecycle,
pool: &crate::sql::Pool,
slug: &str,
name: &str,
secret: &str,
ip: &ClientIp,
) -> Result<MintedToken, MintError> {
let check = crate::tenancy::authenticate_agent_pool(pool, name, secret);
let agent = match gated(slug, ip, check).await.map_err(MintError::Refused)? {
Ok(Some(a)) => a,
Ok(None) => return Err(MintError::Unauthorized),
Err(AgentError::Tenancy(TenancyError::Busy)) => {
return Err(MintError::Refused(LoginRefused::Busy))
}
Err(e) => {
tracing::warn!(error = %e, "mcp agent authentication failed");
return Err(MintError::Internal);
}
};
let agent_id = agent.id.get().copied().unwrap_or_default();
let grants = match agent.user_id {
Some(uid) => crate::tenancy::resolve_user_agent_grants_pool(pool, agent_id, uid).await,
None => crate::tenancy::resolve_agent_grants_pool(pool, agent_id).await,
};
let (skills, tools) = grants.map_err(|e| {
tracing::warn!(error = %e, "mcp grant resolution failed");
MintError::Internal
})?;
let token = issue_agent_token(
jwt,
agent_id,
slug,
&skills,
&tools,
agent.user_id,
&agent.secret_prefix,
)
.map_err(|e| {
tracing::error!(error = %e, "mcp token issuance failed");
MintError::Internal
})?;
Ok(MintedToken {
token,
expires_in: jwt.access_ttl_secs,
scope: skills.join(" "),
})
}
pub(crate) fn agent_token<DB: crate::sql::sqlx::Database>(
t: Tenant<DB>,
State(state): State<AuthedMcpState>,
ip: ClientIp,
Json(input): Json<AgentTokenInput>,
) -> impl std::future::Future<Output = Response> + Send {
agent_token_in(t.into(), state, ip, input)
}
async fn agent_token_in(
t: TenantScope,
state: AuthedMcpState,
ip: ClientIp,
input: AgentTokenInput,
) -> Response {
match mint_agent_jwt(
&state.jwt,
t.pool(),
&t.org.slug,
&input.name,
&input.secret,
&ip,
)
.await
{
Ok(m) => Json(AgentTokenOutput {
access_token: m.token,
token_type: "Bearer",
expires_in: m.expires_in,
})
.into_response(),
Err(MintError::Unauthorized) => {
(StatusCode::UNAUTHORIZED, "invalid agent credentials").into_response()
}
Err(MintError::Internal) => {
(StatusCode::INTERNAL_SERVER_ERROR, "token issuance failed").into_response()
}
Err(MintError::Refused(r)) => r.into_response(),
}
}
pub(crate) enum BearerRejection {
Unauthorized,
CheckFailed,
Refused(LoginRefused),
}
impl From<AgentError> for BearerRejection {
fn from(e: AgentError) -> Self {
if matches!(e, AgentError::Tenancy(TenancyError::Busy)) {
return Self::Refused(LoginRefused::Busy);
}
tracing::warn!(error = %e, "mcp raw-key check failed");
Self::CheckFailed
}
}
impl BearerRejection {
pub(crate) fn into_response(
self,
headers: &HeaderMap,
extensions: &axum::http::Extensions,
uri: &axum::http::Uri,
) -> Response {
match self {
Self::Unauthorized => unauthorized(headers, extensions, uri),
Self::CheckFailed => {
(StatusCode::INTERNAL_SERVER_ERROR, "auth check failed").into_response()
}
Self::Refused(r) => r.into_response(),
}
}
}
pub(crate) async fn authenticate_bearer(
jwt: &JwtLifecycle,
pool: &crate::sql::Pool,
slug: &str,
token: &str,
ip: &ClientIp,
) -> Result<McpAgent, BearerRejection> {
authenticate_bearer_until(jwt, pool, slug, token, ip)
.await
.map(|(agent, _)| agent)
}
pub(crate) async fn authenticate_bearer_until(
jwt: &JwtLifecycle,
pool: &crate::sql::Pool,
slug: &str,
token: &str,
ip: &ClientIp,
) -> Result<(McpAgent, Option<i64>), BearerRejection> {
match verify_agent_claims(jwt, token, slug).await {
Some((mut agent, exp)) => {
match crate::tenancy::agent_token_still_valid_pool(
pool,
agent.agent_id,
agent.user_id,
&agent.secret_prefix,
)
.await
{
Ok(true) => {}
Ok(false) => return Err(BearerRejection::Unauthorized),
Err(e) => {
tracing::warn!(error = %e, "mcp agent liveness re-check failed");
return Err(BearerRejection::CheckFailed);
}
}
let grants = match agent.user_id {
Some(uid) => {
crate::tenancy::resolve_user_agent_grants_pool(pool, agent.agent_id, uid).await
}
None => crate::tenancy::resolve_agent_grants_pool(pool, agent.agent_id).await,
};
(agent.skills, agent.tools) = grants?;
Ok((agent, Some(exp)))
}
None => {
let Some((prefix, secret)) = split_raw_credential(token) else {
return Err(BearerRejection::Unauthorized);
};
let hash_check = async {
let check = crate::tenancy::authenticate_agent_by_prefix_pool(pool, prefix, secret);
match gated(slug, ip, check).await {
Ok(checked) => checked.map_err(BearerRejection::from),
Err(refused) => Err(BearerRejection::Refused(refused)),
}
};
match verify_raw_with(pool, slug, token, prefix, hash_check).await? {
Some(agent) => Ok((agent, None)),
None => Err(BearerRejection::Unauthorized),
}
}
}
}
pub(crate) fn post_authed<DB: crate::sql::sqlx::Database>(
t: Tenant<DB>,
State(state): State<AuthedMcpState>,
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
headers: HeaderMap,
extensions: axum::http::Extensions,
body: Bytes,
) -> impl std::future::Future<Output = Response> + Send {
post_authed_in(t.into(), state, uri, headers, extensions, body)
}
async fn post_authed_in(
t: TenantScope,
state: AuthedMcpState,
uri: axum::http::Uri,
headers: HeaderMap,
extensions: axum::http::Extensions,
body: Bytes,
) -> Response {
let Some(token) = bearer(&headers) else {
return unauthorized(&headers, &extensions, &uri);
};
let ip = ClientIp::from_parts(&extensions, &headers);
let agent = match authenticate_bearer(&state.jwt, t.pool(), &t.org.slug, token, &ip).await {
Ok(agent) => agent,
Err(e) => return e.into_response(&headers, &extensions, &uri),
};
let ctx = super::tools::McpContext {
pool: t.pool().clone(),
agent,
progress: super::progress::ProgressReporter::disabled(),
cancel: super::progress::CancelToken::never(),
};
handle_message(&state.mcp, &body, Some(ctx)).await
}
pub async fn verify_raw_agent_credential(
pool: &crate::sql::Pool,
slug: &str,
token: &str,
) -> Result<Option<McpAgent>, AgentError> {
let Some((prefix, secret)) = split_raw_credential(token) else {
return Ok(None);
};
let hash_check = crate::tenancy::authenticate_agent_by_prefix_pool(pool, prefix, secret);
verify_raw_with(pool, slug, token, prefix, hash_check).await
}
fn split_raw_credential(token: &str) -> Option<(&str, &str)> {
let (prefix, secret) = token.split_once('.')?;
let is_hex = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_hexdigit());
(prefix.len() == 8 && is_hex(prefix) && is_hex(secret)).then_some((prefix, secret))
}
async fn verify_raw_with<E: From<AgentError>>(
pool: &crate::sql::Pool,
slug: &str,
token: &str,
prefix: &str,
hash_check: impl std::future::Future<Output = Result<Option<crate::tenancy::Agent>, E>>,
) -> Result<Option<McpAgent>, E> {
let cache_key = raw_key_cache_key(slug, token);
let agent_id = match raw_key_cache_get(&cache_key) {
Some(id) => id,
None => {
let Some(agent) = hash_check.await? else {
return Ok(None);
};
let agent_id = agent.id.get().copied().unwrap_or_default();
raw_key_cache_put(cache_key, agent_id);
agent_id
}
};
let Some(state) = crate::tenancy::agent_auth_state_pool(pool, agent_id).await? else {
return Ok(None);
};
if !state.active {
raw_key_cache_forget(&cache_key);
return Ok(None);
}
if state.secret_prefix != prefix {
raw_key_cache_forget(&cache_key);
return Ok(None);
}
let user_id = state.user_id;
if let Some(uid) = user_id {
if !crate::tenancy::agent_owner_is_active_pool(pool, uid).await? {
return Ok(None);
}
}
let grants = match user_id {
Some(uid) => crate::tenancy::resolve_user_agent_grants_pool(pool, agent_id, uid).await,
None => crate::tenancy::resolve_agent_grants_pool(pool, agent_id).await,
};
let (skills, tools) = grants?;
Ok(Some(McpAgent {
agent_id,
tenant: slug.to_owned(),
skills,
tools,
user_id,
jti: format!("raw:{agent_id}"),
secret_prefix: state.secret_prefix,
}))
}
const RAW_KEY_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
const RAW_KEY_CACHE_CAP: usize = 256;
type RawKeyCache = std::collections::HashMap<[u8; 32], (i64, std::time::Instant)>;
fn raw_key_cache() -> &'static std::sync::Mutex<RawKeyCache> {
static CACHE: std::sync::OnceLock<std::sync::Mutex<RawKeyCache>> = std::sync::OnceLock::new();
CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
}
fn raw_key_cache_key(slug: &str, token: &str) -> [u8; 32] {
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(slug.as_bytes());
hasher.update([0u8]);
hasher.update(token.as_bytes());
hasher.finalize().into()
}
fn raw_key_cache_get(key: &[u8; 32]) -> Option<i64> {
let cache = raw_key_cache().lock().ok()?;
let (agent_id, cached_at) = cache.get(key)?;
(cached_at.elapsed() < RAW_KEY_CACHE_TTL).then_some(*agent_id)
}
fn raw_key_cache_forget(key: &[u8; 32]) {
if let Ok(mut cache) = raw_key_cache().lock() {
cache.remove(key);
}
}
fn raw_key_cache_put(key: [u8; 32], agent_id: i64) {
let Ok(mut cache) = raw_key_cache().lock() else {
return;
};
if cache.len() >= RAW_KEY_CACHE_CAP && !cache.contains_key(&key) {
cache.retain(|_, (_, at)| at.elapsed() < RAW_KEY_CACHE_TTL);
if cache.len() >= RAW_KEY_CACHE_CAP {
let oldest = cache.iter().min_by_key(|(_, (_, at))| *at).map(|(k, _)| *k);
if let Some(oldest) = oldest {
cache.remove(&oldest);
}
}
}
cache.insert(key, (agent_id, std::time::Instant::now()));
}
#[cfg(test)]
pub(crate) fn raw_key_cache_test_lock() -> &'static tokio::sync::Mutex<()> {
static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
&LOCK
}
pub(crate) fn bearer(headers: &HeaderMap) -> Option<&str> {
crate::bearer::token(headers)
}
pub(crate) fn origin(headers: &HeaderMap, extensions: &axum::http::Extensions) -> String {
let host = headers
.get(header::HOST)
.and_then(|h| h.to_str().ok())
.and_then(crate::urls::HostAuthority::parse)
.map_or_else(|| "localhost".to_owned(), |h| h.to_string());
let scheme = crate::real_ip::trusted_forwarded(headers, extensions, "x-forwarded-proto")
.map(str::to_owned)
.unwrap_or_else(|| {
if crate::urls::is_loopback_host(&host) {
"http".into()
} else {
"https".into()
}
});
format!("{scheme}://{host}")
}
pub(crate) fn mount_base(
headers: &HeaderMap,
extensions: &axum::http::Extensions,
original: &axum::http::Uri,
strip_suffix: &str,
) -> String {
let path = original.path();
let prefix = path
.strip_suffix(strip_suffix)
.unwrap_or(path)
.trim_end_matches('/');
format!("{}{}", origin(headers, extensions), prefix)
}
pub(crate) fn unauthorized(
headers: &HeaderMap,
extensions: &axum::http::Extensions,
original: &axum::http::Uri,
) -> Response {
let base = mount_base(headers, extensions, original, "");
let challenge =
format!(r#"Bearer resource_metadata="{base}/.well-known/oauth-protected-resource""#);
let body = super::types::JsonRpcResponse::failure(
serde_json::Value::Null,
super::types::JsonRpcError::new(
super::types::codes::UNAUTHORIZED,
"missing or invalid agent token",
),
);
(
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, challenge)],
Json(body),
)
.into_response()
}
pub(crate) fn default_jwt() -> Arc<JwtLifecycle> {
Arc::new(JwtLifecycle::lazy(jwt_secret))
}
pub(crate) fn jwt_secret() -> Vec<u8> {
agent_secret(
std::env::var("RUSTANGO_SESSION_SECRET").ok().as_deref(),
&crate::session::tier_from_env(),
std::path::Path::new(crate::session::TENANT_SESSION_KEY_PATH),
)
}
fn agent_secret(raw: Option<&str>, tier: &str, disk: &std::path::Path) -> Vec<u8> {
match agent_key(raw) {
AgentKey::Valid(key) => key,
AgentKey::Unusable(key, e) => {
tracing::warn!(
reason = %e,
"RUSTANGO_SESSION_SECRET is not a valid base64 key; MCP agent tokens still \
sign with its text so issued tokens keep working, but a later release will \
refuse it. Generate one with: openssl rand -base64 32"
);
key
}
AgentKey::Missing if !crate::session::is_prod_tier(tier) => {
crate::session::SessionSecret::from_env_or_disk(disk)
.key()
.to_vec()
}
AgentKey::Missing => {
use rand::RngCore;
tracing::warn!(
"RUSTANGO_SESSION_SECRET unset or <32 bytes; MCP agent tokens \
are signed with an ephemeral per-process key (tokens won't \
survive a restart and won't verify across instances)"
);
let mut key = vec![0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut key);
key
}
}
}
enum AgentKey {
Valid(Vec<u8>),
Unusable(Vec<u8>, crate::session::SessionSecretError),
Missing,
}
fn agent_key(raw: Option<&str>) -> AgentKey {
let Some(raw) = raw.filter(|r| r.len() >= 32) else {
return AgentKey::Missing;
};
match crate::session::SessionSecret::from_b64(raw) {
Ok(_) => AgentKey::Valid(raw.as_bytes().to_vec()),
Err(e) => AgentKey::Unusable(raw.as_bytes().to_vec(), e),
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Uri;
#[test]
fn dev_agent_tokens_sign_with_the_saved_session_key() {
if std::env::var("RUSTANGO_SESSION_SECRET").is_ok() {
return; }
let dir = std::env::temp_dir().join(format!("rustango_mcp_key_{}", std::process::id()));
let path = dir.join("var/.rustango_tenant_session.key");
let first = agent_secret(None, "dev", &path);
assert_eq!(std::fs::read(&path).unwrap(), first, "not the saved key");
assert_eq!(
agent_secret(None, "dev", &path),
first,
"changed on restart"
);
let prod = dir.join("prod.key");
assert_eq!(agent_secret(None, "prod", &prod).len(), 32);
assert!(!prod.exists(), "prod wrote a key file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn building_the_router_logs_nothing() {
let out = crate::testkit::CaptureWriter::default();
let sub = tracing_subscriber::fmt()
.with_writer(out.clone())
.with_ansi(false)
.finish();
tracing::subscriber::with_default(sub, || {
let _ = crate::mcp::secure_tenant_router();
});
assert_eq!(out.contents(), "");
}
#[cfg(all(feature = "sqlite", feature = "testkit"))]
#[tokio::test]
async fn a_jwt_ends_at_rotation_and_reads_live_grants() {
use crate::tenancy::{
create_agent_pool, create_skill_pool, grant_skill_pool, revoke_skill_pool,
rotate_agent_secret_pool,
};
let pool = crate::sql::Pool::connect("sqlite::memory:").await.unwrap();
crate::testkit::migrate_framework(&pool).await.unwrap();
create_skill_pool(&pool, "editor", "Editor", "", "", &["edit".into()])
.await
.unwrap();
let bot = create_agent_pool(&pool, "bot").await.unwrap();
grant_skill_pool(&pool, "acme", "bot", "editor")
.await
.unwrap();
let jwt = JwtLifecycle::new(b"unit-secret-at-least-32-bytes-long!!".to_vec());
let Ok(minted) =
mint_agent_jwt(&jwt, &pool, "acme", "bot", &bot.token, &ClientIp::default()).await
else {
panic!("mint");
};
let tools = || async {
authenticate_bearer(&jwt, &pool, "acme", &minted.token, &ClientIp::default())
.await
.ok()
.map(|a| a.tools)
};
assert_eq!(tools().await, Some(vec!["edit".to_owned()]));
revoke_skill_pool(&pool, "acme", "bot", "editor")
.await
.unwrap();
assert_eq!(tools().await, Some(vec![]), "a revoked skill ends at once");
rotate_agent_secret_pool(&pool, "bot").await.unwrap();
assert_eq!(tools().await, None, "a rotation ends the JWT");
}
#[test]
fn the_agent_key_follows_the_session_secret_rules() {
use base64::Engine as _;
let b64 = |n: usize| base64::engine::general_purpose::STANDARD.encode(vec![7u8; n]);
let good = b64(32);
assert!(matches!(agent_key(Some(&good)), AgentKey::Valid(k) if k == good.as_bytes()));
let pass = "correct horse battery staple and two more";
assert!(matches!(agent_key(Some(pass)), AgentKey::Unusable(k, _) if k == pass.as_bytes()));
assert!(matches!(agent_key(Some(&b64(24))), AgentKey::Unusable(..)));
assert!(matches!(agent_key(Some("short")), AgentKey::Missing));
assert!(matches!(agent_key(None), AgentKey::Missing));
}
fn client(ip: [u8; 4]) -> ClientIp {
let mut ext = no_ext();
ext.insert(crate::real_ip::TrustedRealIp(ip.into()));
ClientIp::from_parts(&ext, &HeaderMap::new())
}
#[cfg(all(feature = "sqlite", feature = "testkit"))]
#[tokio::test]
async fn wrong_agent_secrets_are_limited_per_ip() {
let _g = super::raw_key_cache_test_lock().lock().await;
let pool = crate::sql::Pool::connect("sqlite::memory:").await.unwrap();
crate::testkit::migrate_framework(&pool).await.unwrap();
let bot = crate::tenancy::create_agent_pool(&pool, "bot")
.await
.unwrap();
let jwt = JwtLifecycle::new(b"unit-secret-at-least-32-bytes-long!!".to_vec());
let tries = crate::login_throttle::shared().limits().ip_limit + 5;
let throttled = |r: LoginRefused| matches!(r, LoginRefused::Throttled { .. });
let (slug, ip) = ("t-mint-limit", client([10, 78, 0, 1]));
let mut refused = false;
for _ in 0..tries {
match mint_agent_jwt(&jwt, &pool, slug, "bot", "wrong", &ip).await {
Err(MintError::Unauthorized) => {}
Err(MintError::Refused(r)) if throttled(r) => refused = true,
_ => panic!("unexpected mint outcome"),
}
if refused {
break;
}
}
assert!(refused, "wrong secrets were never limited");
let r = mint_agent_jwt(&jwt, &pool, slug, "bot", &bot.token, &ip).await;
assert!(matches!(
r,
Err(MintError::Refused(LoginRefused::Throttled { .. }))
));
let other = client([10, 78, 0, 2]);
assert!(mint_agent_jwt(&jwt, &pool, slug, "bot", &bot.token, &other)
.await
.is_ok());
let (slug, ip) = ("t-raw-limit", client([10, 78, 1, 1]));
let junk = "abcdef12.0123456789abcdef";
let mut refused = false;
for _ in 0..tries {
match authenticate_bearer(&jwt, &pool, slug, junk, &ip).await {
Err(BearerRejection::Unauthorized) => {}
Err(BearerRejection::Refused(r)) if throttled(r) => refused = true,
_ => panic!("unexpected bearer outcome"),
}
if refused {
break;
}
}
assert!(refused, "wrong raw keys were never limited");
let r = authenticate_bearer(&jwt, &pool, slug, &bot.token, &ip).await;
assert!(matches!(
r,
Err(BearerRejection::Refused(LoginRefused::Throttled { .. }))
));
}
#[tokio::test]
async fn token_round_trips_the_owning_user_id() {
use crate::tenancy::jwt_lifecycle::JwtLifecycle;
let jwt = JwtLifecycle::new(b"unit-secret-at-least-32-bytes-long!!".to_vec());
let token = issue_agent_token(
&jwt,
5,
"acme",
&["coach".into()],
&["log".into()],
Some(99),
"abcd1234",
)
.expect("issue");
let agent = verify_agent_token(&jwt, &token, "acme")
.await
.expect("verify");
assert_eq!(agent.agent_id, 5);
assert_eq!(agent.user_id, Some(99));
assert_eq!(agent.tools, vec!["log"]);
let token = issue_agent_token(&jwt, 5, "acme", &[], &[], None, "abcd1234").expect("issue");
assert_eq!(
verify_agent_token(&jwt, &token, "acme")
.await
.expect("verify")
.user_id,
None
);
}
fn headers(host: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert(header::HOST, host.parse().unwrap());
h
}
fn no_ext() -> axum::http::Extensions {
axum::http::Extensions::new()
}
#[test]
fn forwarded_proto_needs_a_trusted_proxy() {
let mut h = headers("app.example");
h.insert("x-forwarded-proto", "http".parse().unwrap());
assert_eq!(origin(&h, &no_ext()), "https://app.example");
let mut ext = no_ext();
ext.insert(crate::real_ip::TrustedRealIp([10, 0, 0, 1].into()));
assert_eq!(origin(&h, &ext), "http://app.example");
}
#[test]
fn loopback_hosts_advertise_http() {
for host in [
"acme.localhost:8080",
"localhost",
"127.0.0.1:8000",
"[::1]:8080",
] {
assert_eq!(origin(&headers(host), &no_ext()), format!("http://{host}"));
}
assert_eq!(
origin(&headers("example.com"), &no_ext()),
"https://example.com"
);
assert_eq!(
origin(&headers("localhost.example.com"), &no_ext()),
"https://localhost.example.com"
);
let mut h = headers("acme.localhost:8080");
h.insert("x-forwarded-proto", "https".parse().unwrap());
let mut ext = no_ext();
ext.insert(crate::real_ip::TrustedRealIp([10, 0, 0, 1].into()));
assert_eq!(origin(&h, &ext), "https://acme.localhost:8080");
}
#[test]
fn origin_ignores_a_host_with_userinfo() {
for host in ["app.example:1@evil.com:2", "app.example@evil.com"] {
assert_eq!(
origin(&headers(host), &no_ext()),
"http://localhost",
"{host}"
);
}
}
#[test]
fn a_busy_raw_key_check_is_503_not_401() {
use crate::tenancy::{AgentError, TenancyError};
let uri: Uri = "/mcp".parse().unwrap();
let r = BearerRejection::from(AgentError::Tenancy(TenancyError::Busy)).into_response(
&headers("app.example"),
&no_ext(),
&uri,
);
assert_eq!(r.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(r.headers().contains_key(header::RETRY_AFTER));
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn a_failed_raw_key_check_is_500_not_401() {
use crate::tenancy::jwt_lifecycle::JwtLifecycle;
let jwt = JwtLifecycle::new(b"unit-secret-at-least-32-bytes-long!!".to_vec());
let pool = crate::sql::Pool::connect("sqlite::memory:")
.await
.expect("sqlite");
let Err(e) = authenticate_bearer(
&jwt,
&pool,
"acme",
"abcdef12.0123456789abcdef",
&ClientIp::default(),
)
.await
else {
panic!("a failed check must not authenticate");
};
let uri: Uri = "/mcp".parse().unwrap();
let r = e.into_response(&headers("app.example"), &no_ext(), &uri);
assert_eq!(r.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn mount_base_tracks_the_nest_prefix() {
let h = headers("app.example");
let uri: Uri = "/mcp".parse().unwrap();
assert_eq!(
mount_base(&h, &no_ext(), &uri, ""),
"https://app.example/mcp"
);
let uri: Uri = "/api/mcp/.well-known/oauth-protected-resource"
.parse()
.unwrap();
assert_eq!(
mount_base(&h, &no_ext(), &uri, "/.well-known/oauth-protected-resource"),
"https://app.example/api/mcp"
);
let uri: Uri = "/.well-known/oauth-protected-resource".parse().unwrap();
assert_eq!(
mount_base(&h, &no_ext(), &uri, "/.well-known/oauth-protected-resource"),
"https://app.example"
);
}
#[test]
fn unauthorized_challenge_points_at_the_mount_prefix() {
let h = headers("app.example");
let uri: Uri = "/mcp".parse().unwrap();
let resp = unauthorized(&h, &no_ext(), &uri);
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let challenge = resp
.headers()
.get(header::WWW_AUTHENTICATE)
.unwrap()
.to_str()
.unwrap();
assert_eq!(
challenge,
r#"Bearer resource_metadata="https://app.example/mcp/.well-known/oauth-protected-resource""#
);
}
#[test]
fn localhost_origin_uses_http() {
let h = headers("localhost:8080");
let uri: Uri = "/mcp".parse().unwrap();
assert_eq!(
mount_base(&h, &no_ext(), &uri, ""),
"http://localhost:8080/mcp"
);
}
fn cache_lock() -> tokio::sync::MutexGuard<'static, ()> {
super::raw_key_cache_test_lock().blocking_lock()
}
#[test]
fn a_cached_entry_round_trips_and_can_be_forgotten() {
let _g = cache_lock();
raw_key_cache().lock().unwrap().clear();
let key = raw_key_cache_key("acme", "pfx.secret");
assert_eq!(raw_key_cache_get(&key), None, "cold cache");
raw_key_cache_put(key, 7);
assert_eq!(raw_key_cache_get(&key), Some(7));
raw_key_cache_forget(&key);
assert_eq!(
raw_key_cache_get(&key),
None,
"a forgotten entry must not be served again — the reject \
paths call this so the next request re-runs Argon2 rather \
than repeating the same refusal for the rest of the TTL",
);
}
#[test]
fn a_full_cache_evicts_only_the_oldest() {
let _g = cache_lock();
raw_key_cache().lock().unwrap().clear();
let keys: Vec<_> = (0..=RAW_KEY_CACHE_CAP)
.map(|i| raw_key_cache_key("acme", &format!("pfx.{i}")))
.collect();
for (i, key) in keys.iter().enumerate() {
raw_key_cache_put(*key, i as i64);
}
assert_eq!(raw_key_cache_get(&keys[0]), None, "oldest stays cached");
for (i, key) in keys.iter().enumerate().skip(1) {
assert_eq!(raw_key_cache_get(key), Some(i as i64), "entry {i} evicted");
}
let newest = keys.len() - 1;
raw_key_cache_put(keys[newest], newest as i64);
assert_eq!(
raw_key_cache_get(&keys[1]),
Some(1),
"re-put evicted the oldest"
);
raw_key_cache().lock().unwrap().clear();
}
#[test]
fn the_cache_key_is_tenant_scoped() {
let _g = cache_lock();
assert_ne!(
raw_key_cache_key("acme", "pfx.secret"),
raw_key_cache_key("globex", "pfx.secret"),
"the same token under two tenants must not share a slot",
);
}
}