use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use axum::{
body::Body,
extract::{Query, State},
http::{Request, StatusCode},
middleware::Next,
response::{Html, IntoResponse, Json, Response},
Form,
};
use base64::Engine;
use candid::Principal;
use ic_agent::identity::{Delegation, SignedDelegation};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use tokio::sync::RwLock;
use uuid::Uuid;
use crate::identities::Identities;
const CONNECT_TTL: Duration = Duration::from_secs(600);
const CODE_TTL: Duration = Duration::from_secs(120);
const TOKEN_TTL: Duration = Duration::from_secs(3600);
const GRANT_TTL_SECS: u64 = 3600;
const MAX_CLIENTS: usize = 10_000;
const MAX_REDIRECT_URIS: usize = 16;
const MAX_REDIRECT_URI_LEN: usize = 2_048;
const PERSIST_MIN_INTERVAL: Duration = Duration::from_secs(2);
const MAX_CODES: usize = 4_096;
const MAX_TOKENS: usize = 20_000;
pub(crate) const CONNECT_COOKIE: &str = "mcp_connect";
#[derive(Clone, Debug, Serialize, Deserialize)]
struct ClientReg {
#[serde(default)]
redirect_uris: Vec<String>,
#[serde(default = "now_secs")]
last_used: u64,
}
impl ClientReg {
fn new(redirect_uris: Vec<String>) -> Self {
Self { redirect_uris, last_used: now_secs() }
}
}
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
}
const CLIENTS_FILENAME: &str = "oauth-clients.json";
fn clients_tmp_path(path: &Path) -> PathBuf {
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
PathBuf::from(tmp)
}
fn load_clients_from(path: &Path) -> HashMap<String, ClientReg> {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
tracing::warn!("could not parse {}: {e}; starting with no clients", path.display());
HashMap::new()
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
Err(e) => {
tracing::warn!("could not read {}: {e}; starting with no clients", path.display());
HashMap::new()
}
}
}
fn persist_clients_to(path: &Path, clients: &HashMap<String, ClientReg>) {
let bytes = match serde_json::to_vec_pretty(clients) {
Ok(bytes) => bytes,
Err(e) => {
tracing::warn!("could not serialize client registrations: {e}");
return;
}
};
let tmp = clients_tmp_path(path);
if let Err(e) = std::fs::write(&tmp, &bytes) {
tracing::warn!("could not write {}: {e}", tmp.display());
return;
}
if let Err(e) = std::fs::rename(&tmp, path) {
tracing::warn!("could not replace {}: {e}", path.display());
let _ = std::fs::remove_file(&tmp);
}
}
struct ClientStore {
registrations: RwLock<HashMap<String, ClientReg>>,
state_dir: PathBuf,
dirty: AtomicBool,
writing: AtomicBool,
}
impl ClientStore {
fn load(state_dir: PathBuf) -> Arc<Self> {
let registrations = load_clients_from(&state_dir.join(CLIENTS_FILENAME));
Self::with(registrations, state_dir)
}
fn with(registrations: HashMap<String, ClientReg>, state_dir: PathBuf) -> Arc<Self> {
Arc::new(Self {
registrations: RwLock::new(registrations),
state_dir,
dirty: AtomicBool::new(false),
writing: AtomicBool::new(false),
})
}
fn file(&self) -> PathBuf {
self.state_dir.join(CLIENTS_FILENAME)
}
async fn redirect_allowed_for(&self, client_id: &str, redirect_uri: &str) -> bool {
let mut clients = self.registrations.write().await;
if !redirect_allowed(clients.get(client_id), redirect_uri) {
return false;
}
if let Some(reg) = clients.get_mut(client_id) {
reg.last_used = now_secs();
}
true
}
#[cfg(test)]
async fn seed(&self, client_id: &str, redirect_uris: Vec<&str>) {
let reg = ClientReg::new(redirect_uris.into_iter().map(str::to_string).collect());
self.registrations.write().await.insert(client_id.to_string(), reg);
}
async fn register(self: &Arc<Self>, client_id: String, reg: ClientReg) {
{
let mut clients = self.registrations.write().await;
make_room_for_client(&mut clients);
clients.insert(client_id, reg);
}
self.persist_soon();
}
fn persist_soon(self: &Arc<Self>) {
self.dirty.store(true, Ordering::SeqCst);
if self.writing.swap(true, Ordering::SeqCst) {
return; }
let store = Arc::clone(self);
tokio::spawn(async move {
loop {
while store.dirty.swap(false, Ordering::SeqCst) {
let snapshot = store.registrations.read().await.clone();
let file = store.file();
tokio::task::spawn_blocking(move || persist_clients_to(&file, &snapshot))
.await
.ok();
tokio::time::sleep(PERSIST_MIN_INTERVAL).await;
}
store.writing.store(false, Ordering::SeqCst);
if !store.dirty.load(Ordering::SeqCst) || store.writing.swap(true, Ordering::SeqCst) {
break;
}
}
});
}
}
fn make_room_for_client(clients: &mut HashMap<String, ClientReg>) {
while clients.len() >= MAX_CLIENTS {
let Some(victim) = clients
.iter()
.min_by_key(|(_, c)| c.last_used)
.map(|(id, _)| id.clone())
else {
break;
};
clients.remove(&victim);
}
}
fn make_room<K, V>(map: &mut HashMap<K, V>, cap: usize, remaining: impl Fn(&V) -> Duration)
where
K: Clone + Eq + std::hash::Hash,
{
if map.len() < cap {
return;
}
map.retain(|_, v| !remaining(v).is_zero());
while map.len() >= cap {
let Some(victim) = map.iter().min_by_key(|(_, v)| remaining(v)).map(|(k, _)| k.clone()) else {
break;
};
map.remove(&victim);
}
}
const DEFAULT_ALLOWED_REDIRECTS: &[(&str, &str)] = &[
("antigravity.google", "/oauth-callback"), ("chatgpt.com", "/connector/oauth/"), ("claude.ai", "/api/mcp/auth_callback"), ("cursor.com", "/agents/mcp/oauth/callback"), ("grok.com", "/connector/oauth/"), ("grok.com", "/connectors-oauth-exchange-code/"),
("grok.com", "/mcp/callback"),
("perplexity.ai", "/rest/connections/oauth_callback"), ("perplexity.com", "/rest/connections/oauth_callback"),
];
fn allowed_redirects() -> &'static [(String, String)] {
static CACHE: std::sync::OnceLock<Vec<(String, String)>> = std::sync::OnceLock::new();
CACHE.get_or_init(|| {
let mut out: Vec<(String, String)> =
DEFAULT_ALLOWED_REDIRECTS.iter().map(|(d, p)| (d.to_string(), p.to_string())).collect();
if let Ok(extra) = std::env::var("OAUTH_ALLOWED_REDIRECT_PREFIXES") {
for raw in extra.split([',', ' ', '\t', '\n']).map(str::trim).filter(|s| !s.is_empty()) {
match parse_redirect_prefix(raw) {
Some(e) => out.push(e),
None => tracing::warn!(
"ignoring OAUTH_ALLOWED_REDIRECT_PREFIXES entry `{raw}`: must be a bare \
`https://host/path` with a non-root path prefix and no non-default port \
(`:443` is fine), query, fragment, or userinfo (domain-wide entries are \
refused)"
),
}
}
}
out
})
}
fn parse_redirect_prefix(raw: &str) -> Option<(String, String)> {
let u = url::Url::parse(raw).ok().filter(|u| u.scheme() == "https")?;
if u.port().is_some()
|| u.query().is_some()
|| u.fragment().is_some()
|| !u.username().is_empty()
|| u.password().is_some()
{
return None;
}
let host = u.host_str()?.trim_end_matches('.').to_ascii_lowercase();
let path = u.path().to_string();
(!host.is_empty() && path != "/" && !path.is_empty()).then_some((host, path))
}
fn path_within_prefix(path: &str, prefix: &str) -> bool {
match path.strip_prefix(prefix) {
Some(rest) => rest.is_empty() || prefix.ends_with('/') || rest.starts_with('/'),
None => false,
}
}
fn path_has_percent_encoding(path: &str) -> bool {
path.contains('%')
}
fn redirect_uri_permitted(redirect_uri: &str) -> bool {
let Ok(url) = url::Url::parse(redirect_uri) else {
return false;
};
if url.query().is_some() || url.fragment().is_some() {
return false;
}
if is_loopback_url(&url) {
return true;
}
let Some(host) = url.host_str() else {
return false;
};
let Ok(canonical) = url::Url::parse(&format!("https://{host}{}", url.path())) else {
return false;
};
if url != canonical {
return false;
}
let host = host.trim_end_matches('.').to_ascii_lowercase();
let path = url.path();
if path_has_percent_encoding(path) {
return false;
}
allowed_redirects().iter().any(|(domain, prefix)| {
(host == *domain || host.strip_suffix(domain.as_str()).is_some_and(|p| p.ends_with('.')))
&& path_within_prefix(path, prefix)
})
}
fn is_wellformed_hosted_redirect(redirect_uri: &str) -> bool {
match url::Url::parse(redirect_uri) {
Ok(url) => {
url.scheme() == "https"
&& url.username().is_empty()
&& url.password().is_none()
&& url.host_str().is_some_and(|h| !h.is_empty())
&& url.query().is_none()
&& url.fragment().is_none()
&& !path_has_percent_encoding(url.path())
}
Err(_) => false,
}
}
fn redirect_allowed(reg: Option<&ClientReg>, redirect_uri: &str) -> bool {
let Some(reg) = reg else { return false };
if !redirect_uri_permitted(redirect_uri) {
return false;
}
reg.redirect_uris
.iter()
.any(|u| u == redirect_uri || loopback_match(u, redirect_uri))
}
fn loopback_match(registered: &str, requested: &str) -> bool {
if !is_loopback_redirect(registered) || !is_loopback_redirect(requested) {
return false;
}
let (Ok(a), Ok(b)) = (url::Url::parse(registered), url::Url::parse(requested)) else {
return false;
};
a.host_str() == b.host_str() && a.path() == b.path() && a.query() == b.query()
}
#[derive(Clone)]
pub struct AuthStore {
clients: Arc<ClientStore>,
tokens: Arc<RwLock<HashMap<String, TokenInfo>>>,
authz: Arc<RwLock<HashMap<String, AuthzPending>>>,
codes: Arc<RwLock<HashMap<String, CodeGrant>>>,
identities: Identities,
public_url: String,
mcp_path: String,
require_resource: bool,
}
#[derive(Clone, Debug)]
struct AuthzPending {
client_id: String,
redirect_uri: String,
client_state: String,
code_challenge: Option<String>,
cookie: String,
created: Instant,
code: Option<String>,
redeeming: bool,
}
impl AuthzPending {
fn remaining(&self) -> Duration {
CONNECT_TTL.saturating_sub(self.created.elapsed())
}
}
#[derive(Clone, Debug)]
struct CodeGrant {
client_id: String,
code_challenge: Option<String>,
session_id: String,
created: Instant,
}
impl CodeGrant {
fn remaining(&self) -> Duration {
CODE_TTL.saturating_sub(self.created.elapsed())
}
}
#[derive(Clone, Debug)]
struct TokenInfo {
principal: String,
session_id: String,
created: Instant,
ttl: Duration,
}
impl TokenInfo {
fn remaining(&self) -> Duration {
self.ttl.saturating_sub(self.created.elapsed())
}
}
#[derive(Clone)]
pub struct SharedClients(Arc<ClientStore>);
impl SharedClients {
pub fn load(state_dir: impl AsRef<Path>) -> Self {
Self(ClientStore::load(state_dir.as_ref().to_path_buf()))
}
pub fn state_dir(&self) -> &Path {
&self.0.state_dir
}
}
impl AuthStore {
pub fn new(
identities: Identities,
clients: SharedClients,
public_url: String,
mcp_path: String,
require_resource: bool,
) -> Self {
Self {
clients: clients.0,
tokens: Arc::default(),
authz: Arc::default(),
codes: Arc::default(),
identities,
public_url,
mcp_path,
require_resource,
}
}
fn instance(&self) -> &crate::identities::IiInstance {
self.identities.instance()
}
pub(crate) fn state_dir(&self) -> &Path {
&self.clients.state_dir
}
fn issuer(&self) -> String {
format!("{}{}", self.public_url, self.mcp_path)
}
fn resource_metadata_url(&self) -> String {
format!(
"{}/.well-known/oauth-protected-resource{}",
self.public_url, self.mcp_path
)
}
async fn validate_client(&self, client_id: &str, redirect_uri: &str) -> bool {
self.clients.redirect_allowed_for(client_id, redirect_uri).await
}
pub async fn session_for_token(&self, token: &str) -> Option<(String, String)> {
let tokens = self.tokens.read().await;
let info = tokens.get(token)?;
(!info.remaining().is_zero()).then(|| (info.principal.clone(), info.session_id.clone()))
}
async fn insert_pending(&self, session_id: String, pending: AuthzPending) {
let mut authz = self.authz.write().await;
make_room(&mut authz, crate::identities::MAX_PENDING_CONNECTS, AuthzPending::remaining);
authz.insert(session_id, pending);
}
pub(crate) async fn reap_expired(&self) -> ReapedOauthState {
let mut reaped = ReapedOauthState::default();
{
let mut authz = self.authz.write().await;
let before = authz.len();
authz.retain(|_, a| !a.remaining().is_zero());
reaped.pending = before - authz.len();
}
{
let mut codes = self.codes.write().await;
let before = codes.len();
codes.retain(|_, c| !c.remaining().is_zero());
reaped.codes = before - codes.len();
}
{
let mut tokens = self.tokens.write().await;
let before = tokens.len();
tokens.retain(|_, t| !t.remaining().is_zero());
reaped.tokens = before - tokens.len();
}
if reaped.any() {
tracing::debug!(
pending_connects = reaped.pending,
codes = reaped.codes,
tokens = reaped.tokens,
"reaped expired OAuth state"
);
}
reaped
}
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ReapedOauthState {
pending: usize,
codes: usize,
tokens: usize,
}
impl ReapedOauthState {
fn any(&self) -> bool {
self.pending + self.codes + self.tokens > 0
}
}
#[derive(Debug, Deserialize)]
pub struct AuthorizeQuery {
#[serde(default)]
response_type: Option<String>,
client_id: String,
redirect_uri: String,
#[serde(default)]
state: Option<String>,
#[serde(default)]
code_challenge: Option<String>,
#[serde(default)]
code_challenge_method: Option<String>,
#[allow(dead_code)]
#[serde(default)]
scope: Option<String>,
#[serde(default)]
resource: Option<String>,
}
fn raw_authority_has_userinfo(raw: &str) -> bool {
raw.split_once("://")
.map(|(_, rest)| rest.split(['/', '?', '#']).next().unwrap_or(rest))
.is_some_and(|authority| authority.contains('@'))
}
fn resource_matches_issuer(resource: &str, issuer: &str) -> bool {
let (Ok(got), Ok(want)) = (url::Url::parse(resource), url::Url::parse(issuer)) else {
return false;
};
if got.fragment().is_some()
|| resource.contains(['\t', '\n', '\r'])
|| !got.username().is_empty()
|| got.password().is_some()
|| raw_authority_has_userinfo(resource)
{
return false;
}
let norm = |u: &url::Url| {
(
u.scheme().to_owned(),
u.host_str().map(str::to_owned),
u.port_or_known_default(),
u.path().strip_suffix('/').unwrap_or(u.path()).to_owned(),
u.query().map(str::to_owned),
)
};
norm(&got) == norm(&want)
}
pub async fn authorize(
State(store): State<AuthStore>,
headers: axum::http::HeaderMap,
Query(q): Query<AuthorizeQuery>,
) -> Response {
const MALFORMED_DIAGNOSTIC: &str = "Your MCP client sent a request this server can't process. \
The client may be out of date. Try updating it. If that doesn't help, remove the connector \
and add it again. Then sign in.";
const SIGNIN_HEADLINE: &str = "We couldn't start your sign-in.";
match q.response_type.as_deref() {
Some("code") => {}
Some(_) => {
return signin_error(&headers, StatusCode::BAD_REQUEST, "unsupported_response_type",
"only response_type=code", SIGNIN_HEADLINE, MALFORMED_DIAGNOSTIC)
}
None => {
return signin_error(&headers, StatusCode::BAD_REQUEST, "invalid_request",
"response_type=code required", SIGNIN_HEADLINE, MALFORMED_DIAGNOSTIC)
}
}
if !store.validate_client(&q.client_id, &q.redirect_uri).await {
if !redirect_uri_permitted(&q.redirect_uri) {
if is_wellformed_hosted_redirect(&q.redirect_uri) {
return if accepts_html(&headers) {
not_allowlisted_page()
} else {
oauth_err(StatusCode::FORBIDDEN, "invalid_client",
&format!("redirect_uri is not on the hosted-redirect allow-list; contact {CONTACT} to request access"))
};
}
return signin_error(&headers, StatusCode::BAD_REQUEST, "invalid_request",
"redirect_uri must be a valid https or loopback URL", SIGNIN_HEADLINE,
MALFORMED_DIAGNOSTIC);
}
return signin_error(&headers, StatusCode::BAD_REQUEST, "invalid_client",
"unknown client_id / redirect_uri", SIGNIN_HEADLINE,
"This server doesn't recognize your MCP client. Its registration may have expired. \
Remove the connector and add it again. Then sign in.");
}
let Some(code_challenge) = q.code_challenge.clone() else {
return signin_error(&headers, StatusCode::BAD_REQUEST, "invalid_request",
"code_challenge (PKCE S256) required", SIGNIN_HEADLINE,
"Your MCP client's request was missing a required security check (PKCE). The client may \
be out of date. Try updating it. If that doesn't help, remove the connector and add it again.");
};
if q.code_challenge_method.as_deref() != Some("S256") {
return signin_error(&headers, StatusCode::BAD_REQUEST, "invalid_request",
"code_challenge_method=S256 is required", SIGNIN_HEADLINE,
"Your MCP client's request used an unsupported PKCE method (only S256 is supported). \
The client may be out of date. Try updating it. If that doesn't help, remove the \
connector and add it again.");
}
match q.resource.as_deref() {
Some(resource) if resource_matches_issuer(resource, &store.issuer()) => {}
Some(_) => {
return signin_error(&headers, StatusCode::BAD_REQUEST, "invalid_target",
"the `resource` does not identify this MCP server (RFC 8707)", SIGNIN_HEADLINE,
"Your MCP client requested sign-in for a different server than this one. Update your \
client or reconnect; if you were connecting a third-party tool, check that it's the \
one you intended.");
}
None if store.require_resource => {
tracing::warn!("refusing an authorize with no RFC 8707 `resource` (strict mode)");
return signin_error(&headers, StatusCode::BAD_REQUEST, "invalid_request",
"the `resource` parameter is required (RFC 8707)", SIGNIN_HEADLINE,
"Your MCP client didn't say which server it's signing in to (the RFC 8707 `resource` \
parameter). The client may be out of date. Try updating it, then reconnect.");
}
None => {}
}
let session_id = format!("sess-{}", Uuid::new_v4());
let reg_pubkey = match store.identities.registration_pubkey_b64(&session_id).await {
Ok(k) => k,
Err(e) => {
tracing::warn!("refusing a connect: {e}");
return signin_error(&headers, StatusCode::SERVICE_UNAVAILABLE, "temporarily_unavailable",
"the server is at capacity for sessions; retry shortly", SIGNIN_HEADLINE,
"This server is busy right now, so it couldn't start a new sign-in. Wait a moment \
and try again.");
}
};
let cookie = format!("bind-{}", Uuid::new_v4());
store
.insert_pending(
session_id.clone(),
AuthzPending {
client_id: q.client_id.clone(),
redirect_uri: q.redirect_uri.clone(),
client_state: q.state.clone().unwrap_or_default(),
code_challenge: Some(code_challenge),
cookie: cookie.clone(),
created: Instant::now(),
code: None,
redeeming: false,
},
)
.await;
let is_https = store
.public_url
.split_once("://")
.is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("https"));
let secure = if is_https { "; Secure" } else { "" };
let set_cookie = format!(
"{CONNECT_COOKIE}={cookie}; Path={}/oauth; Max-Age={}; HttpOnly{secure}; SameSite=Lax",
store.mcp_path,
CONNECT_TTL.as_secs(),
);
let ii_url = ii_mcp_url(&store, &session_id, ®_pubkey);
let mut resp = redirect_302(&ii_url);
resp.headers_mut().insert(
axum::http::header::SET_COOKIE,
axum::http::HeaderValue::from_str(&set_cookie).expect("valid cookie"),
);
resp
}
fn connect_cookie(headers: &axum::http::HeaderMap) -> Option<String> {
let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
raw.split(';')
.filter_map(|kv| kv.trim().split_once('='))
.find(|(k, _)| *k == CONNECT_COOKIE)
.map(|(_, v)| v.to_string())
}
fn redirect_302(url: &str) -> Response {
let mut resp = (StatusCode::FOUND, [(axum::http::header::LOCATION, url.to_string())]).into_response();
resp.headers_mut()
.insert(axum::http::header::REFERRER_POLICY, axum::http::HeaderValue::from_static("no-referrer"));
resp
}
fn build_redirect(redirect_uri: &str, code: &str, client_state: &str, iss: &str) -> String {
let sep = if redirect_uri.contains('?') { '&' } else { '?' };
let mut r = format!("{redirect_uri}{sep}code={}", urlencoding::encode(code));
if !client_state.is_empty() {
r.push_str(&format!("&state={}", urlencoding::encode(client_state)));
}
r.push_str(&format!("&iss={}", urlencoding::encode(iss)));
r
}
fn ii_mcp_url(store: &AuthStore, session_id: &str, reg_pubkey_b64: &str) -> String {
format!(
"{ii}/mcp#callback={cb}&state={st}&ttl={ttl}®istration_key={rk}",
ii = store.instance().ii_url,
cb = urlencoding::encode(&connect_callback_url(store)),
st = urlencoding::encode(session_id),
ttl = GRANT_TTL_SECS,
rk = urlencoding::encode(reg_pubkey_b64),
)
}
pub const AUTH_CALLBACKS_WELL_KNOWN: &str = "/.well-known/ii-auth-callbacks";
fn connect_callback_url(store: &AuthStore) -> String {
format!("{}/oauth/connect/callback", store.issuer())
}
pub async fn auth_callbacks(State(stores): State<Vec<AuthStore>>) -> Response {
let callbacks: Vec<String> = stores.iter().map(connect_callback_url).collect();
let mut resp = Json(json!({ "callbacks": callbacks })).into_response();
resp.headers_mut().insert(
axum::http::header::CACHE_CONTROL,
axum::http::HeaderValue::from_static("no-store"),
);
resp
}
pub async fn connect_callback_page(State(store): State<AuthStore>) -> Response {
pinned_callback_page(&store.mcp_path)
}
fn csp_nonce() -> String {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("getrandom");
base64::engine::general_purpose::STANDARD.encode(bytes)
}
const CONNECT_PAGE_CSS: &str = include_str!("assets/connect.css");
const CONNECT_LOGO_SVG: &str = include_str!("assets/dfinity-logo.svg");
const PINNED_PAGE_HTML: &str = include_str!("assets/connect-callback.html");
const PINNED_PAGE_JS: &str = r#"(function () {
function show(t, err) {
document.getElementById('m').textContent = t;
if (err) {
var c = document.querySelector('.screen');
if (c) { c.classList.add('error'); }
}
}
// II delivers #delegation=<chain JSON>&state=<state>: the two-hop chain plus
// the connect state, percent-encoded by URLSearchParams and decoded again by
// it here. Consent (permissions, max_ttl) is NOT in the fragment: the user
// chose it earlier at II's prepare step, which stored it keyed by P_reg, and
// mcp_register_v2 recovers it server-side. So the page forwards only the chain
// and the state; the backend redeems with mcp_register_v2(session_key).
var params = new URLSearchParams(location.hash.slice(1));
var body = JSON.stringify({
state: params.get('state') || '',
delegation: params.get('delegation') || ''
});
// Scrub the delegation from the address bar, keeping the path and any query
// string the declared callback carries. Best-effort: the POST below works
// even if a browser refuses the history call.
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
fetch("__REDEEM_URL__", {
method: 'POST',
headers: { 'content-type': 'application/json' },
credentials: 'same-origin',
body: body
})
.then(function (r) { return r.json().catch(function () { return {}; }); })
.then(function (d) {
if (d && d.redirect) {
location.replace(d.redirect);
} else {
show((d && d.error) || "We couldn't finish the connection. Restart from your client.", true);
}
})
.catch(function () {
show("We couldn't reach the server. Restart from your client.", true);
});
})();"#;
fn pinned_callback_page(prefix: &str) -> Response {
let nonce = csp_nonce();
let redeem = js_escape(&format!("{prefix}/oauth/connect/redeem"));
let script = PINNED_PAGE_JS.replace("__REDEEM_URL__", &redeem);
let html = PINNED_PAGE_HTML
.replace("__NONCE__", &nonce)
.replace("__CSS__", CONNECT_PAGE_CSS)
.replace("__LOGO__", CONNECT_LOGO_SVG)
.replace("__CONTACT__", CONTACT)
.replace("__SCRIPT__", &script);
let csp = format!(
"default-src 'none'; script-src 'nonce-{nonce}'; style-src 'nonce-{nonce}'; \
connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
);
let mut resp = Html(html).into_response();
let h = resp.headers_mut();
h.insert(
axum::http::header::CONTENT_SECURITY_POLICY,
axum::http::HeaderValue::from_str(&csp).expect("valid CSP"),
);
h.insert(
axum::http::header::REFERRER_POLICY,
axum::http::HeaderValue::from_static("no-referrer"),
);
h.insert(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
);
h.insert(
axum::http::header::X_FRAME_OPTIONS,
axum::http::HeaderValue::from_static("DENY"),
);
resp
}
const CONTACT: &str = "mcp@dfinity.org";
const CONNECT_ERROR_HTML: &str = include_str!("assets/connect-error.html");
const SIGNIN_ERROR_TITLE: &str = "Sign-in error";
fn accepts_html(headers: &axum::http::HeaderMap) -> bool {
let Some(accept) = headers.get(axum::http::header::ACCEPT).and_then(|v| v.to_str().ok()) else {
return false;
};
accept.split(',').any(|range| {
let mut parts = range.split(';').map(str::trim);
if !parts.next().is_some_and(|media| media.eq_ignore_ascii_case("text/html")) {
return false;
}
!parts.any(|param| {
param
.split_once('=')
.is_some_and(|(k, v)| k.trim().eq_ignore_ascii_case("q")
&& v.trim().parse::<f32>().is_ok_and(|q| q <= 0.0))
})
})
}
fn contact_report_hint() -> String {
format!("If this error is unexpected, please contact <a href=\"mailto:{CONTACT}\">{CONTACT}</a> to report it.")
}
fn error_screen(status: StatusCode, title: &str, headline: &str, detail: &str, hint: &str) -> Response {
let nonce = csp_nonce();
let html = CONNECT_ERROR_HTML
.replace("__NONCE__", &nonce)
.replace("__CSS__", CONNECT_PAGE_CSS)
.replace("__LOGO__", CONNECT_LOGO_SVG)
.replace("__TITLE__", title)
.replace("__HEADLINE__", headline)
.replace("__DETAIL__", detail)
.replace("__HINT__", hint);
let csp = format!(
"default-src 'none'; style-src 'nonce-{nonce}'; base-uri 'none'; \
form-action 'none'; frame-ancestors 'none'"
);
let mut resp = (status, Html(html)).into_response();
let h = resp.headers_mut();
h.insert(
axum::http::header::CONTENT_SECURITY_POLICY,
axum::http::HeaderValue::from_str(&csp).expect("valid CSP"),
);
h.insert(
axum::http::header::X_CONTENT_TYPE_OPTIONS,
axum::http::HeaderValue::from_static("nosniff"),
);
h.insert(
axum::http::header::X_FRAME_OPTIONS,
axum::http::HeaderValue::from_static("DENY"),
);
resp
}
fn signin_error(
headers: &axum::http::HeaderMap,
status: StatusCode,
error: &str,
desc: &str,
headline: &str,
diagnostic: &str,
) -> Response {
if accepts_html(headers) {
error_screen(status, SIGNIN_ERROR_TITLE, headline, diagnostic, &contact_report_hint())
} else {
oauth_err(status, error, desc)
}
}
fn not_allowlisted_page() -> Response {
error_screen(
StatusCode::FORBIDDEN,
"MCP client not approved",
"This MCP client isn't approved yet.",
"This server only accepts approved MCP clients. Yours isn't on the allow-list yet.",
&format!(
"To request access, email <a href=\"mailto:{CONTACT}\">{CONTACT}</a>. Tell us the name \
of your MCP client or AI chatbot."
),
)
}
#[derive(Deserialize)]
pub struct RedeemBody {
state: String,
#[serde(default)]
delegation: String,
}
const MAX_REG_DELEGATION_JSON: usize = 64 * 1024;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct JsonDelegationChain {
delegations: Vec<JsonSignedDelegation>,
#[serde(rename = "publicKey")]
public_key: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct JsonSignedDelegation {
delegation: JsonDelegation,
signature: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct JsonDelegation {
pubkey: String,
expiration: String,
#[serde(default)]
targets: Option<Vec<String>>,
}
fn hex_decode(field: &str, s: &str) -> Result<Vec<u8>, String> {
hex::decode(s.trim()).map_err(|e| format!("{field} is not valid hex: {e}"))
}
fn parse_registration_delegation(delegation_json: &str) -> Result<(Vec<u8>, Vec<SignedDelegation>), String> {
if delegation_json.len() > MAX_REG_DELEGATION_JSON {
return Err(format!("delegation exceeds {MAX_REG_DELEGATION_JSON} bytes"));
}
let chain: JsonDelegationChain =
serde_json::from_str(delegation_json).map_err(|e| format!("delegation JSON: {e}"))?;
let user_key = hex_decode("publicKey", &chain.public_key)?;
let delegations = chain
.delegations
.iter()
.map(|d| {
let targets = match &d.delegation.targets {
None => None,
Some(ts) => Some(
ts.iter()
.map(|t| {
Principal::from_text(t.trim())
.map_err(|e| format!("delegation target principal: {e}"))
})
.collect::<Result<Vec<_>, _>>()?,
),
};
Ok(SignedDelegation {
delegation: Delegation {
pubkey: hex_decode("delegation pubkey", &d.delegation.pubkey)?,
expiration: u64::from_str_radix(d.delegation.expiration.trim(), 16)
.map_err(|_| "delegation expiration is not a hex u64".to_string())?,
targets,
permissions: None,
},
signature: hex_decode("delegation signature", &d.signature)?,
})
})
.collect::<Result<Vec<_>, String>>()?;
Ok((user_key, delegations))
}
fn redeem_err(msg: &str) -> Response {
(StatusCode::BAD_REQUEST, Json(json!({ "error": msg }))).into_response()
}
enum RedeemClaim {
Claimed,
Existing(String),
InProgress,
Vanished,
}
async fn claim_redemption(store: &AuthStore, state: &str) -> RedeemClaim {
let mut authz = store.authz.write().await;
let Some(a) = authz.get_mut(state) else {
return RedeemClaim::Vanished;
};
if let Some(code) = &a.code {
return RedeemClaim::Existing(code.clone());
}
if a.redeeming {
return RedeemClaim::InProgress;
}
a.redeeming = true;
RedeemClaim::Claimed
}
async fn release_redemption(store: &AuthStore, state: &str) {
if let Some(a) = store.authz.write().await.get_mut(state) {
a.redeeming = false;
}
}
pub async fn connect_redeem(
State(store): State<AuthStore>,
headers: axum::http::HeaderMap,
Json(body): Json<RedeemBody>,
) -> Response {
let snap = {
let authz = store.authz.read().await;
authz.get(&body.state).map(|a| {
(
a.remaining().is_zero(),
a.cookie.clone(),
a.client_id.clone(),
a.redirect_uri.clone(),
a.client_state.clone(),
a.code_challenge.clone(),
a.code.clone(),
)
})
};
let Some((expired, cookie, client_id, redirect_uri, client_state, code_challenge, existing_code)) = snap else {
return redeem_err("This connect request is unknown or already used. Restart from your client.");
};
if expired {
return redeem_err("This connect request has expired. Restart from your client.");
}
if connect_cookie(&headers).as_deref() != Some(cookie.as_str()) {
return redeem_err(
"This sign-in started in a different browser. Restart from your client.",
);
}
let iss = store.issuer();
if let Some(code) = existing_code {
return Json(json!({ "redirect": build_redirect(&redirect_uri, &code, &client_state, &iss) })).into_response();
}
let (user_key, chain) = match parse_registration_delegation(&body.delegation) {
Ok(v) => v,
Err(e) => return redeem_err(&format!("We couldn't read the sign-in response. Restart from your client. ({e})")),
};
match claim_redemption(&store, &body.state).await {
RedeemClaim::Claimed => {}
RedeemClaim::Existing(code) => {
return Json(json!({ "redirect": build_redirect(&redirect_uri, &code, &client_state, &iss) }))
.into_response()
}
RedeemClaim::InProgress => {
return redeem_err(
"This connect request is already being processed. Wait a moment. \
If nothing happens, restart from your client.",
)
}
RedeemClaim::Vanished => return redeem_err("This connect request is no longer available. Restart from your client."),
}
match store
.identities
.redeem_registration_delegation(&body.state, user_key, chain)
.await
{
Ok(outcome) => {
tracing::info!(
state = %body.state,
expiration_ns = outcome.expiration_ns,
permissions = ?outcome.permissions,
"registration delegation redeemed"
);
}
Err(e) => {
release_redemption(&store, &body.state).await;
return redeem_err(&e);
}
}
let fresh = format!("mcp-code-{}", Uuid::new_v4());
let (code, newly_minted) = {
let mut authz = store.authz.write().await;
let Some(a) = authz.get_mut(&body.state) else {
return redeem_err("This connect request is no longer available. Restart from your client.");
};
a.redeeming = false;
match &a.code {
Some(existing) => (existing.clone(), false),
None => {
a.code = Some(fresh.clone());
(fresh, true)
}
}
};
if newly_minted {
let mut codes = store.codes.write().await;
make_room(&mut codes, MAX_CODES, CodeGrant::remaining);
codes.insert(
code.clone(),
CodeGrant {
client_id,
code_challenge,
session_id: body.state.clone(),
created: Instant::now(),
},
);
}
tracing::info!(session_id = %body.state, "grant confirmed via registration delegation; issued authorization code");
Json(json!({ "redirect": build_redirect(&redirect_uri, &code, &client_state, &iss) })).into_response()
}
fn js_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"").replace('<', "\\x3c")
}
#[derive(Debug, Deserialize)]
pub struct TokenForm {
grant_type: String,
#[serde(default)]
code: String,
#[serde(default)]
client_id: String,
#[serde(default)]
code_verifier: Option<String>,
#[serde(default)]
resource: Option<String>,
}
pub async fn token(State(store): State<AuthStore>, Form(req): Form<TokenForm>) -> Response {
match req.grant_type.as_str() {
"authorization_code" => token_authorization_code(store, req).await,
_ => oauth_err(StatusCode::BAD_REQUEST, "unsupported_grant_type", "only authorization_code is supported"),
}
}
async fn token_authorization_code(store: AuthStore, req: TokenForm) -> Response {
match req.resource.as_deref() {
Some(resource) if resource_matches_issuer(resource, &store.issuer()) => {}
Some(_) => {
return oauth_err(StatusCode::BAD_REQUEST, "invalid_target",
"the `resource` does not identify this MCP server (RFC 8707)");
}
None if store.require_resource => {
tracing::warn!("refusing a token request with no RFC 8707 `resource` (strict mode)");
return oauth_err(StatusCode::BAD_REQUEST, "invalid_request",
"the `resource` parameter is required (RFC 8707)");
}
None => {}
}
let grant = match store.codes.write().await.remove(&req.code) {
Some(g) if !g.remaining().is_zero() => g,
Some(_) => return oauth_err(StatusCode::BAD_REQUEST, "invalid_grant", "code expired"),
None => return oauth_err(StatusCode::BAD_REQUEST, "invalid_grant", "unknown or used code"),
};
if !req.client_id.is_empty() && req.client_id != grant.client_id {
return oauth_err(StatusCode::BAD_REQUEST, "invalid_client", "client_id mismatch");
}
if let Some(challenge) = &grant.code_challenge {
let verifier = match &req.code_verifier {
Some(v) => v,
None => return oauth_err(StatusCode::BAD_REQUEST, "invalid_grant", "code_verifier required"),
};
if &pkce_s256(verifier) != challenge {
return oauth_err(StatusCode::BAD_REQUEST, "invalid_grant", "PKCE verification failed");
}
}
store.authz.write().await.remove(&grant.session_id);
issue_token(&store, &grant.session_id).await
}
fn token_ttl(default: Duration, grant_expiration_ns: Option<u64>, now_ns: u64) -> Duration {
match grant_expiration_ns {
Some(exp_ns) => Duration::from_nanos(exp_ns.saturating_sub(now_ns)),
None => default,
}
}
async fn issue_token(store: &AuthStore, session_id: &str) -> Response {
let principal = store
.identities
.session_principal(session_id)
.await
.unwrap_or_else(|| "unknown".to_string());
let now_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
let ttl = token_ttl(
TOKEN_TTL,
store.identities.grant_expiration_ns(session_id).await,
now_ns,
);
let access_token = format!("mcp-token-{}", Uuid::new_v4());
{
let mut tokens = store.tokens.write().await;
make_room(&mut tokens, MAX_TOKENS, TokenInfo::remaining);
tokens.insert(
access_token.clone(),
TokenInfo {
principal: principal.clone(),
session_id: session_id.to_string(),
created: Instant::now(),
ttl,
},
);
}
tracing::info!(%principal, ttl_secs = ttl.as_secs(), "issued MCP access token");
Json(json!({
"access_token": access_token,
"token_type": "Bearer",
"expires_in": ttl.as_secs(),
}))
.into_response()
}
fn pkce_s256(verifier: &str) -> String {
let digest = Sha256::digest(verifier.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
}
#[derive(Debug, Deserialize)]
pub struct RegisterRequest {
#[serde(default)]
client_name: Option<String>,
#[serde(default)]
redirect_uris: Vec<String>,
#[serde(default)]
grant_types: Vec<String>,
}
fn granted_grant_types(requested: &[String]) -> Option<Vec<String>> {
const SUPPORTED: [&str; 1] = ["authorization_code"];
let granted: Vec<String> = if requested.is_empty() {
SUPPORTED.iter().map(|s| s.to_string()).collect()
} else {
let mut g: Vec<String> = requested
.iter()
.filter(|g| SUPPORTED.contains(&g.as_str()))
.cloned()
.collect();
g.dedup();
g
};
granted.iter().any(|g| g == "authorization_code").then_some(granted)
}
pub async fn register(State(store): State<AuthStore>, Json(req): Json<RegisterRequest>) -> Response {
if req.redirect_uris.len() > MAX_REDIRECT_URIS {
return oauth_err(
StatusCode::BAD_REQUEST,
"invalid_redirect_uri",
&format!(
"too many redirect_uris ({}, max {MAX_REDIRECT_URIS})",
req.redirect_uris.len()
),
);
}
if let Some(bad) = req.redirect_uris.iter().find(|u| u.len() > MAX_REDIRECT_URI_LEN) {
return oauth_err(
StatusCode::BAD_REQUEST,
"invalid_redirect_uri",
&format!(
"a redirect_uri is too long ({} bytes, max {MAX_REDIRECT_URI_LEN})",
bad.len()
),
);
}
let Some(granted) = granted_grant_types(&req.grant_types) else {
return oauth_err(
StatusCode::BAD_REQUEST,
"invalid_client_metadata",
"this server only supports the authorization_code grant; request it (or omit grant_types)",
);
};
if let Some(bad) = req.redirect_uris.iter().find(|u| !redirect_uri_permitted(u.as_str())) {
return oauth_err(
StatusCode::BAD_REQUEST,
"invalid_redirect_uri",
&format!(
"redirect_uri {bad} is not permitted: a hosted redirect must be https on an \
allow-listed domain AND under that vendor's registered OAuth-callback path \
(loopback redirects are always allowed). To have this MCP client added to the \
allow-list, contact {CONTACT}."
),
);
}
let client_id = format!("client-{}", Uuid::new_v4());
store.clients.register(client_id.clone(), ClientReg::new(req.redirect_uris.clone())).await;
let mut resp = json!({
"client_id": client_id,
"redirect_uris": req.redirect_uris,
"token_endpoint_auth_method": "none",
"grant_types": granted,
"response_types": ["code"],
});
if let Some(name) = req.client_name {
resp["client_name"] = json!(name);
}
(StatusCode::CREATED, Json(resp)).into_response()
}
pub async fn authorization_server_metadata(State(store): State<AuthStore>) -> Response {
let issuer = store.issuer();
Json(json!({
"issuer": issuer,
"authorization_endpoint": format!("{issuer}/oauth/authorize"),
"token_endpoint": format!("{issuer}/oauth/token"),
"registration_endpoint": format!("{issuer}/oauth/register"),
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"authorization_response_iss_parameter_supported": true,
}))
.into_response()
}
pub async fn protected_resource_metadata(State(store): State<AuthStore>) -> Response {
let issuer = store.issuer();
Json(json!({
"resource": issuer,
"authorization_servers": [issuer],
}))
.into_response()
}
#[derive(Clone, Debug)]
pub struct AuthedSession {
pub session_id: String,
}
pub async fn require_token(State(store): State<AuthStore>, mut request: Request<Body>, next: Next) -> Response {
let token = request
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.and_then(|h| {
let (scheme, rest) = h.split_once(' ')?;
scheme.eq_ignore_ascii_case("Bearer").then(|| rest.trim().to_owned())
});
let had_token = token.is_some();
let session = match token {
Some(t) => store.session_for_token(&t).await,
None => None,
};
match session {
Some((principal, session_id)) => {
tracing::debug!(%principal, %session_id, "authenticated MCP request");
store.identities.touch_session(&session_id).await;
request.extensions_mut().insert(AuthedSession { session_id });
next.run(request).await
}
None => (
StatusCode::UNAUTHORIZED,
[(
axum::http::header::WWW_AUTHENTICATE,
bearer_challenge(had_token, &store.resource_metadata_url()),
)],
Json(json!({ "error": "invalid_token" })),
)
.into_response(),
}
}
fn bearer_challenge(had_token: bool, resource_metadata_url: &str) -> String {
let meta = format!("resource_metadata=\"{resource_metadata_url}\"");
if had_token {
format!("Bearer error=\"invalid_token\", error_description=\"The access token is invalid or expired\", {meta}")
} else {
format!("Bearer {meta}")
}
}
fn is_loopback_redirect(redirect_uri: &str) -> bool {
url::Url::parse(redirect_uri).map(|u| is_loopback_url(&u)).unwrap_or(false)
}
fn is_loopback_url(url: &url::Url) -> bool {
url.scheme() == "http"
&& url.username().is_empty()
&& url.password().is_none()
&& matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))
}
fn oauth_err(status: StatusCode, error: &str, desc: &str) -> Response {
(status, Json(json!({ "error": error, "error_description": desc }))).into_response()
}
pub type _JsonValue = Value;
#[cfg(test)]
mod tests {
use std::{collections::HashMap, time::Duration};
use super::{
build_redirect, is_loopback_redirect, pkce_s256, redirect_allowed, redirect_uri_permitted,
ClientReg,
};
#[test]
fn pkce_s256_matches_rfc_vector() {
let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
assert_eq!(pkce_s256(verifier), expected);
}
#[test]
fn client_store_persists_atomically() {
use super::{clients_tmp_path, load_clients_from, persist_clients_to};
let path = std::env::temp_dir().join(format!("imcp2-clients-{}.json", std::process::id()));
let tmp = clients_tmp_path(&path);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&tmp);
let mut old = HashMap::new();
old.insert(
"client-old".to_string(),
ClientReg::new(vec!["http://127.0.0.1:1111/old".to_string()]),
);
persist_clients_to(&path, &old);
assert!(path.exists(), "old snapshot must exist first");
let mut clients = HashMap::new();
clients.insert(
"client-abc".to_string(),
ClientReg::new(vec!["http://127.0.0.1:4321/cb".to_string()]),
);
persist_clients_to(&path, &clients);
let loaded = load_clients_from(&path);
assert_eq!(loaded.len(), 1, "replace swaps the whole snapshot");
assert!(loaded.contains_key("client-abc"), "new entry present");
assert!(!loaded.contains_key("client-old"), "old entry replaced, not kept");
assert_eq!(
loaded["client-abc"].redirect_uris,
vec!["http://127.0.0.1:4321/cb".to_string()]
);
assert!(!tmp.exists(), "no leftover .tmp file");
let raw = std::fs::read(&path).unwrap();
assert!(serde_json::from_slice::<HashMap<String, ClientReg>>(&raw).is_ok());
let _ = std::fs::remove_file(&path);
}
#[test]
fn redirect_requires_registration() {
let reg = ClientReg::new(vec!["https://claude.ai/api/mcp/auth_callback".to_string()]);
assert!(redirect_allowed(Some(®), "https://claude.ai/api/mcp/auth_callback"));
assert!(!redirect_allowed(Some(®), "https://claude.ai/api/mcp/auth_callback/x"));
assert!(!redirect_allowed(None, "https://claude.ai/api/mcp/auth_callback"));
assert!(!redirect_allowed(None, "http://127.0.0.1:51000/callback"));
assert!(!redirect_allowed(None, "http://[::1]:8080/cb"));
}
#[test]
fn hosted_redirect_allow_list() {
assert!(redirect_uri_permitted("https://claude.ai/api/mcp/auth_callback"));
assert!(redirect_uri_permitted("https://chatgpt.com/connector/oauth/abc"));
assert!(redirect_uri_permitted("https://grok.com/mcp/callback"));
assert!(redirect_uri_permitted("https://grok.com/connectors-oauth-exchange-code/x"));
assert!(redirect_uri_permitted("https://www.perplexity.ai/rest/connections/oauth_callback"));
assert!(redirect_uri_permitted("https://staging.perplexity.com/rest/connections/oauth_callback"));
assert!(redirect_uri_permitted("https://antigravity.google/oauth-callback"));
assert!(redirect_uri_permitted("http://127.0.0.1:6112/cb"));
assert!(redirect_uri_permitted("http://localhost/callback"));
assert!(redirect_uri_permitted("http://[::1]:8080/cb"));
assert!(!redirect_uri_permitted("https://perplexity.ai/page/attacker"));
assert!(!redirect_uri_permitted("https://www.perplexity.ai/page/attacker"));
assert!(!redirect_uri_permitted("https://chatgpt.com/g/evil-gpt"));
assert!(!redirect_uri_permitted("https://chatgpt.com/share/abcd"));
assert!(!redirect_uri_permitted("https://claude.ai/foo"));
assert!(!redirect_uri_permitted("https://claude.ai/api/mcp/auth_callbackEVIL"));
assert!(!redirect_uri_permitted("https://chatgpt.com/connector/oauth/../../g/evil"));
assert!(!redirect_uri_permitted("https://chatgpt.com/connector/oauth/%2e%2e/%2e%2e/g/evil"));
assert!(!redirect_uri_permitted("https://chatgpt.com/connector/oauth/%2E%2E/%2E%2E/g/evil"));
assert!(redirect_uri_permitted("https://chatgpt.com/connector/oauth/x/../y"));
assert!(!redirect_uri_permitted(
"https://chatgpt.com/connector/oauth/%2e%2e%2f%2e%2e%2fg%2fattacker"
));
assert!(!redirect_uri_permitted("https://chatgpt.com/connector/oauth/%2E%2E%2Fg%2Fevil"));
assert!(!redirect_uri_permitted("https://chatgpt.com/connector/oauth/x%2fy"));
assert!(!redirect_uri_permitted("https://chatgpt.com/connector/oauth/x%5cy"));
assert!(!redirect_uri_permitted("https://claude.ai/api/mcp/auth_callback%2f%2e%2e"));
assert!(!redirect_uri_permitted("https://chatgpt.com/connector/oauth/a%20b"));
assert!(!redirect_uri_permitted("https://chatgpt.com/connector/oauth/%41"));
assert!(!super::is_wellformed_hosted_redirect(
"https://chatgpt.com/connector/oauth/%2e%2e%2f%2e%2e%2fg%2fattacker"
));
assert!(redirect_uri_permitted("https://www.cursor.com/agents/mcp/oauth/callback"));
assert!(!redirect_uri_permitted("https://cursor.com/oauth/callback")); assert!(!redirect_uri_permitted("https://vscode.dev/redirect"));
assert!(!redirect_uri_permitted("https://insiders.vscode.dev/redirect"));
assert!(!redirect_uri_permitted("https://example.com/cb"));
assert!(!redirect_uri_permitted("https://attacker.example/cb"));
assert!(!redirect_uri_permitted("https://claude.ai.evil.com/api/mcp/auth_callback"));
assert!(!redirect_uri_permitted("https://evilclaude.ai/api/mcp/auth_callback"));
assert!(!redirect_uri_permitted("https://claude.ai@evil.com/api/mcp/auth_callback"));
assert!(!redirect_uri_permitted("https://user@claude.ai/api/mcp/auth_callback"));
assert!(!redirect_uri_permitted("https://user:pass@claude.ai/api/mcp/auth_callback"));
assert!(!redirect_uri_permitted("https://claude.ai:444/api/mcp/auth_callback"));
assert!(redirect_uri_permitted("https://claude.ai:443/api/mcp/auth_callback"));
assert!(!redirect_uri_permitted("http://claude.ai/api/mcp/auth_callback"));
assert!(!redirect_uri_permitted("https://claude.ai/api/mcp/auth_callback?code=123"));
assert!(!redirect_uri_permitted("https://claude.ai/api/mcp/auth_callback?x=1"));
assert!(!redirect_uri_permitted("https://claude.ai/api/mcp/auth_callback#frag"));
assert!(!redirect_uri_permitted("http://127.0.0.1:6112/cb?code=123"));
assert!(!redirect_uri_permitted("http://localhost/callback#frag"));
let junk = ClientReg::new(vec!["https://example.com/cb".to_string()]);
assert!(!redirect_allowed(Some(&junk), "https://example.com/cb"));
}
#[test]
fn redirect_prefix_entry_parsing() {
use super::parse_redirect_prefix;
assert_eq!(
parse_redirect_prefix("https://vendor.example/mcp/callback"),
Some(("vendor.example".to_string(), "/mcp/callback".to_string()))
);
assert_eq!(
parse_redirect_prefix("https://VENDOR.example/mcp/callback"),
Some(("vendor.example".to_string(), "/mcp/callback".to_string()))
);
assert_eq!(
parse_redirect_prefix("https://vendor.example:443/mcp/callback"),
Some(("vendor.example".to_string(), "/mcp/callback".to_string()))
);
assert_eq!(parse_redirect_prefix("https://vendor.example:8443/mcp/callback"), None);
assert_eq!(parse_redirect_prefix("https://vendor.example/mcp/callback?x=1"), None);
assert_eq!(parse_redirect_prefix("https://vendor.example/mcp/callback#frag"), None);
assert_eq!(parse_redirect_prefix("https://user@vendor.example/mcp/callback"), None);
assert_eq!(parse_redirect_prefix("http://vendor.example/mcp/callback"), None);
assert_eq!(parse_redirect_prefix("https://vendor.example/"), None);
assert_eq!(parse_redirect_prefix("https://vendor.example"), None);
assert_eq!(parse_redirect_prefix("not a url"), None);
}
#[test]
fn registered_loopback_matches_any_port() {
let reg = ClientReg::new(vec!["http://localhost:54321/callback".to_string()]);
assert!(redirect_allowed(Some(®), "http://localhost:54321/callback"));
assert!(redirect_allowed(Some(®), "http://localhost:61832/callback"));
assert!(redirect_allowed(Some(®), "http://localhost/callback"));
assert!(!redirect_allowed(Some(®), "http://localhost:61832/other"));
assert!(!redirect_allowed(Some(®), "http://127.0.0.1:61832/callback"));
assert!(!redirect_allowed(Some(®), "http://localhost.evil.com:54321/callback"));
let hosted = ClientReg::new(vec!["https://claude.ai/cb".to_string()]);
assert!(!redirect_allowed(Some(&hosted), "http://localhost:1234/cb"));
}
#[test]
fn loopback_rejects_lookalikes() {
assert!(is_loopback_redirect("http://127.0.0.1:51000/callback"));
assert!(is_loopback_redirect("http://[::1]:8080/cb"));
assert!(!is_loopback_redirect("http://localhost.evil.com/cb"));
assert!(!is_loopback_redirect("http://localhost@evil.com/cb"));
assert!(!is_loopback_redirect("http://localhost:1234@evil.com/cb"));
assert!(!is_loopback_redirect("https://evil.com/cb"));
}
#[test]
fn bearer_challenge_carries_error_only_for_presented_tokens() {
let meta = "https://x.test/.well-known/oauth-protected-resource/mcp-beta";
let with_token = super::bearer_challenge(true, meta);
assert!(with_token.starts_with("Bearer "));
assert!(with_token.contains("error=\"invalid_token\""));
assert!(with_token.contains("error_description="));
assert!(with_token.contains(&format!("resource_metadata=\"{meta}\"")));
let no_token = super::bearer_challenge(false, meta);
assert!(no_token.starts_with("Bearer "));
assert!(!no_token.contains("error="), "a bare challenge must omit the error code: {no_token}");
assert!(no_token.contains(&format!("resource_metadata=\"{meta}\"")));
}
#[test]
fn connect_cookie_extracts_named_value() {
use axum::http::{header::COOKIE, HeaderMap, HeaderValue};
let mut h = HeaderMap::new();
assert_eq!(super::connect_cookie(&h), None);
h.insert(
COOKIE,
HeaderValue::from_static("other=1; mcp_connect=bind-xyz; last=2"),
);
assert_eq!(super::connect_cookie(&h).as_deref(), Some("bind-xyz"));
let mut h2 = HeaderMap::new();
h2.insert(COOKIE, HeaderValue::from_static("session=abc"));
assert_eq!(super::connect_cookie(&h2), None);
}
#[test]
fn token_ttl_tracks_grant_expiration() {
use std::time::Duration;
let default = Duration::from_secs(3600);
let now_ns: u64 = 1_000_000_000_000_000_000;
assert_eq!(super::token_ttl(default, None, now_ns), default);
let far = now_ns + 86_400 * 1_000_000_000;
assert_eq!(
super::token_ttl(default, Some(far), now_ns),
Duration::from_secs(86_400)
);
let soon = now_ns + 600 * 1_000_000_000;
assert_eq!(
super::token_ttl(default, Some(soon), now_ns),
Duration::from_secs(600)
);
assert_eq!(
super::token_ttl(default, Some(now_ns - 1), now_ns),
Duration::ZERO
);
}
#[test]
fn granted_grant_types_intersects_and_refuses_codeless() {
let g = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(super::granted_grant_types(&[]), Some(g(&["authorization_code"])));
assert_eq!(
super::granted_grant_types(&g(&["authorization_code", "refresh_token"])),
Some(g(&["authorization_code"]))
);
assert_eq!(super::granted_grant_types(&g(&["refresh_token"])), None);
assert_eq!(super::granted_grant_types(&g(&["client_credentials"])), None);
}
#[test]
fn build_redirect_encodes_code_state_and_iss() {
let iss = "https://mcp.example/mcp";
let r = build_redirect("https://claude.ai/cb", "mcp-code-1", "abc/def", iss);
assert_eq!(
r,
"https://claude.ai/cb?code=mcp-code-1&state=abc%2Fdef&iss=https%3A%2F%2Fmcp.example%2Fmcp"
);
let r2 = build_redirect("https://x.test/cb?foo=1", "c", "", iss);
assert_eq!(r2, "https://x.test/cb?foo=1&code=c&iss=https%3A%2F%2Fmcp.example%2Fmcp");
}
fn test_store() -> super::AuthStore {
test_store_cfg(false)
}
fn test_store_cfg(require_resource: bool) -> super::AuthStore {
use crate::identities::{Identities, IiInstance};
use candid::Principal;
let agent = crate::Agent::builder()
.with_url("https://ii.test")
.build()
.expect("test agent");
let ids = Identities::new(
IiInstance {
name: "test",
ii_url: "https://ii.test".into(),
ii_canister: Principal::anonymous(),
},
"https://mcp.test".into(),
agent,
);
super::AuthStore::new(
ids,
super::SharedClients(super::ClientStore::with(
std::collections::HashMap::new(),
std::env::temp_dir(),
)),
"https://mcp.test".into(),
"/mcp".into(),
require_resource,
)
}
fn html_headers() -> axum::http::HeaderMap {
let mut h = axum::http::HeaderMap::new();
h.insert(
axum::http::header::ACCEPT,
axum::http::HeaderValue::from_static("text/html,application/xhtml+xml,*/*"),
);
h
}
fn json_headers() -> axum::http::HeaderMap {
let mut h = axum::http::HeaderMap::new();
h.insert(
axum::http::header::ACCEPT,
axum::http::HeaderValue::from_static("application/json"),
);
h
}
async fn seed_pending(store: &super::AuthStore, id: &str, cookie: &str) {
store.insert_pending(
id.to_string(),
super::AuthzPending {
client_id: "c".into(),
redirect_uri: "https://app.test/cb".into(),
client_state: String::new(),
code_challenge: Some("cc".into()),
cookie: cookie.into(),
created: std::time::Instant::now(),
code: None,
redeeming: false,
},
)
.await;
}
#[test]
fn make_room_drops_expired_before_the_soonest_to_expire() {
let mut map: HashMap<&str, Duration> = HashMap::new();
map.insert("expired", Duration::ZERO);
map.insert("expires-soon", Duration::from_secs(1));
map.insert("expires-later", Duration::from_secs(60));
super::make_room(&mut map, 3, |remaining| *remaining);
assert_eq!(map.len(), 2, "one slot freed");
assert!(!map.contains_key("expired"), "the expired entry goes first");
super::make_room(&mut map, 2, |remaining| *remaining);
assert_eq!(map.len(), 1);
assert!(map.contains_key("expires-later") && !map.contains_key("expires-soon"));
}
#[tokio::test]
async fn pending_connects_are_capped() {
let cap = crate::identities::MAX_PENDING_CONNECTS;
let store = test_store();
for i in 0..cap + 16 {
seed_pending(&store, &format!("sess-{i}"), "bind").await;
}
let authz = store.authz.read().await;
assert_eq!(authz.len(), cap, "the map never grows past its cap");
assert!(
authz.contains_key(&format!("sess-{}", cap + 15)),
"the connect just started is never the one evicted"
);
}
#[test]
fn make_room_for_client_evicts_least_recently_used() {
let mut clients: HashMap<String, ClientReg> = (0..super::MAX_CLIENTS)
.map(|i| {
let mut reg = ClientReg::new(vec!["http://localhost/cb".to_string()]);
reg.last_used = 1_000 + i as u64; (format!("client-{i}"), reg)
})
.collect();
super::make_room_for_client(&mut clients);
assert_eq!(clients.len(), super::MAX_CLIENTS - 1, "room for exactly one more client");
assert!(!clients.contains_key("client-0"), "the least-recently-used registration goes");
assert!(clients.contains_key(&format!("client-{}", super::MAX_CLIENTS - 1)));
}
#[tokio::test]
async fn a_used_client_is_marked_recently_used() {
let store = test_store();
let redirect = "https://claude.ai/api/mcp/auth_callback";
store.clients.seed("client-x", vec![redirect]).await;
let backdate = || async {
store.clients.registrations.write().await.get_mut("client-x").expect("client").last_used = 0;
};
let stamp = || async { store.clients.registrations.read().await["client-x"].last_used };
backdate().await;
assert!(store.validate_client("client-x", redirect).await);
assert!(stamp().await > 0, "an accepted redirect refreshes the LRU stamp");
backdate().await;
assert!(!store.validate_client("client-x", "https://claude.ai/api/mcp/auth_callback/nope").await);
assert_eq!(stamp().await, 0, "a rejected redirect must not refresh the stamp");
}
#[tokio::test]
async fn reap_expired_drops_dead_state_and_keeps_live_state() {
let store = test_store();
seed_pending(&store, "sess-live", "bind-live").await;
let token = |ttl: Duration| super::TokenInfo {
principal: "p".into(),
session_id: "sess-live".into(),
created: std::time::Instant::now(),
ttl,
};
{
let mut tokens = store.tokens.write().await;
tokens.insert("dead".into(), token(Duration::ZERO));
tokens.insert("live".into(), token(Duration::from_secs(3600)));
}
let reaped = store.reap_expired().await;
assert_eq!(reaped.tokens, 1, "the lapsed token is dropped");
assert_eq!(reaped.pending, 0, "a connect still inside its TTL is kept");
assert_eq!(reaped.codes, 0);
let tokens = store.tokens.read().await;
assert!(tokens.contains_key("live") && !tokens.contains_key("dead"));
assert!(store.authz.read().await.contains_key("sess-live"));
}
#[test]
fn v2_link_carries_registration_key() {
let store = test_store();
let url = super::ii_mcp_url(&store, "sess-1", "PUBX");
assert!(url.starts_with("https://ii.test/mcp#"), "everything rides the fragment: {url}");
assert!(url.contains("state=sess-1"));
assert!(url.contains("registration_key=PUBX"));
let encoded = urlencoding::encode("https://mcp.test/mcp/oauth/connect/callback").into_owned();
assert!(url.contains(&format!("callback={encoded}")), "callback under the mount: {url}");
}
#[tokio::test]
async fn auth_callbacks_declares_link_callbacks_verbatim() {
use axum::extract::State;
use crate::identities::{Identities, IiInstance};
use candid::Principal;
let make = |mcp_path: &'static str| {
let agent = crate::Agent::builder()
.with_url("https://ii.test")
.build()
.expect("test agent");
super::AuthStore::new(
Identities::new(
IiInstance {
name: "t",
ii_url: "https://ii.test".into(),
ii_canister: Principal::anonymous(),
},
"https://mcp.test".into(),
agent,
),
super::SharedClients(super::ClientStore::with(
std::collections::HashMap::new(),
std::env::temp_dir(),
)),
"https://mcp.test".into(),
mcp_path.into(),
false,
)
};
let prod = make("/mcp");
let beta = make("/mcp-beta");
let r = super::auth_callbacks(State(vec![prod.clone(), beta.clone()])).await;
assert_eq!(r.status(), axum::http::StatusCode::OK);
assert!(
r.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.is_some_and(|ct| ct.starts_with("application/json")),
"II requires an application/json content type"
);
assert_eq!(
r.headers().get(axum::http::header::CACHE_CONTROL).and_then(|v| v.to_str().ok()),
Some("no-store"),
"the allow-list must be non-cacheable"
);
let body = axum::body::to_bytes(r.into_body(), usize::MAX).await.unwrap();
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
let declared: Vec<String> = v["callbacks"]
.as_array()
.expect("callbacks array")
.iter()
.map(|e| e.as_str().unwrap().to_string())
.collect();
assert_eq!(declared.len(), 2, "one entry per instance");
for (store, link) in [
(&prod, super::ii_mcp_url(&prod, "s", "K")),
(&beta, super::ii_mcp_url(&beta, "s", "K")),
] {
let expected = super::connect_callback_url(store);
assert!(declared.contains(&expected), "{expected} must be declared: {declared:?}");
let encoded = format!("callback={}", urlencoding::encode(&expected));
assert!(link.contains(&encoded), "the II link must embed the declared URL: {link}");
}
for d in &declared {
assert!(d.starts_with("https://mcp.test"), "same-origin entries only: {d}");
assert!(!d.contains('#'), "no fragments in declared callbacks: {d}");
}
}
#[tokio::test]
async fn authorize_redirects_302_with_fragment_cookie_and_no_referrer() {
use axum::extract::{Query, State};
let store = test_store();
store.clients.seed("client-x", vec!["https://claude.ai/api/mcp/auth_callback"]).await;
let resp = super::authorize(
State(store.clone()),
axum::http::HeaderMap::new(),
Query(super::AuthorizeQuery {
response_type: Some("code".into()),
client_id: "client-x".into(),
redirect_uri: "https://claude.ai/api/mcp/auth_callback".into(),
state: Some("xyz".into()),
code_challenge: Some("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".into()),
code_challenge_method: Some("S256".into()),
scope: None,
resource: None,
}),
)
.await;
assert_eq!(resp.status(), axum::http::StatusCode::FOUND, "authorize must 302, not render a page");
let h = resp.headers();
let location = h.get(axum::http::header::LOCATION).unwrap().to_str().unwrap();
assert!(location.starts_with("https://ii.test/mcp#"), "redirects to the II /mcp link: {location}");
for needle in ["callback=", "state=", "registration_key="] {
assert!(location.contains(needle), "fragment must carry `{needle}`: {location}");
}
assert_eq!(
h.get(axum::http::header::REFERRER_POLICY).unwrap().to_str().unwrap(),
"no-referrer",
"the redirect must set Referrer-Policy: no-referrer"
);
let cookie = h.get(axum::http::header::SET_COOKIE).unwrap().to_str().unwrap();
assert!(
cookie.contains(&format!("{}=", super::CONNECT_COOKIE)),
"the binding cookie must be set: {cookie}"
);
}
#[test]
fn resource_matches_issuer_accepts_only_this_instance() {
let issuer = "https://mcp.test/mcp";
for ok in [
"https://mcp.test/mcp",
"https://mcp.test/mcp/", "https://MCP.test/mcp", "HTTPS://mcp.test/mcp", "https://mcp.test:443/mcp", ] {
assert!(super::resource_matches_issuer(ok, issuer), "must accept {ok}");
}
for bad in [
"https://other.example/mcp",
"https://mcp.test/mcp-beta",
"https://mcp.test:8443/mcp",
"https://mcp.test/mcp#x",
"https://user@mcp.test/mcp", "https://@mcp.test/mcp", "https://:@mcp.test/mcp", "https:\t//user@mcp.test/mcp", "https://mcp.test\n/mcp", "https://mcp.test/mcp?tenant=other", "https://mcp.test/mcp//", "http://mcp.test/mcp", "not-a-url",
] {
assert!(!super::resource_matches_issuer(bad, issuer), "must refuse {bad}");
}
}
#[tokio::test]
async fn authorize_rejects_foreign_resource_indicator() {
use axum::extract::{Query, State};
let store = test_store();
store.clients.seed("client-x", vec!["https://claude.ai/api/mcp/auth_callback"]).await;
let mk = |resource: Option<&str>| super::AuthorizeQuery {
response_type: Some("code".into()),
client_id: "client-x".into(),
redirect_uri: "https://claude.ai/api/mcp/auth_callback".into(),
state: Some("xyz".into()),
code_challenge: Some(super::pkce_s256("verifier")),
code_challenge_method: Some("S256".into()),
scope: None,
resource: resource.map(str::to_owned),
};
let foreign = super::authorize(State(store.clone()), json_headers(), Query(mk(Some("https://other.example/mcp")))).await;
assert_eq!(foreign.status(), axum::http::StatusCode::BAD_REQUEST, "a foreign resource must be refused");
let body = axum::body::to_bytes(foreign.into_body(), usize::MAX).await.unwrap();
assert_eq!(serde_json::from_slice::<serde_json::Value>(&body).unwrap()["error"], "invalid_target");
let sibling = super::authorize(State(store.clone()), json_headers(), Query(mk(Some("https://mcp.test/mcp-beta")))).await;
assert_eq!(sibling.status(), axum::http::StatusCode::BAD_REQUEST, "a sibling instance's resource must be refused");
for ok in ["https://mcp.test/mcp", "https://mcp.test/mcp/"] {
let resp = super::authorize(State(store.clone()), axum::http::HeaderMap::new(), Query(mk(Some(ok)))).await;
assert_eq!(resp.status(), axum::http::StatusCode::FOUND, "the canonical resource must be accepted: {ok}");
}
let none = super::authorize(State(store.clone()), axum::http::HeaderMap::new(), Query(mk(None))).await;
assert_eq!(none.status(), axum::http::StatusCode::FOUND, "a missing resource must remain accepted");
}
#[tokio::test]
async fn token_endpoint_enforces_resource_indicator() {
use axum::{body::Body, http::{header, Request, StatusCode}, middleware, routing::post, Router};
use tower::ServiceExt;
let store = test_store();
let make_app = |store: super::AuthStore| {
let protected = Router::new()
.route("/mcp", post(|| async { "authenticated" }))
.route_layer(middleware::from_fn_with_state(store.clone(), super::require_token));
Router::new()
.route("/oauth/token", post(super::token))
.merge(protected)
.with_state(store)
};
let seed_code = || super::CodeGrant {
client_id: "mcp-client".into(),
code_challenge: Some(super::pkce_s256("verifier")),
session_id: "mcp-session".into(),
created: std::time::Instant::now(),
};
let body_for = |code: &str, resource: Option<&str>| {
let mut b = format!("grant_type=authorization_code&code={code}&client_id=mcp-client&code_verifier=verifier");
if let Some(r) = resource {
b.push_str(&format!("&resource={}", urlencoding::encode(r)));
}
b
};
store.codes.write().await.insert("proof-code".into(), seed_code());
let refused = make_app(store.clone())
.oneshot(
Request::post("/oauth/token")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body_for("proof-code", Some("https://other.example/mcp"))))
.unwrap(),
)
.await
.unwrap();
assert_eq!(refused.status(), StatusCode::BAD_REQUEST, "a foreign resource must be refused at /oauth/token");
let body = axum::body::to_bytes(refused.into_body(), usize::MAX).await.unwrap();
assert_eq!(serde_json::from_slice::<serde_json::Value>(&body).unwrap()["error"], "invalid_target");
assert!(store.codes.read().await.contains_key("proof-code"), "a refused request must not consume the code");
let exchange = make_app(store.clone())
.oneshot(
Request::post("/oauth/token")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body_for("proof-code", Some("https://mcp.test/mcp"))))
.unwrap(),
)
.await
.unwrap();
assert_eq!(exchange.status(), StatusCode::OK, "the canonical resource must be accepted");
let body = axum::body::to_bytes(exchange.into_body(), usize::MAX).await.unwrap();
let token = serde_json::from_slice::<serde_json::Value>(&body).unwrap()["access_token"].as_str().unwrap().to_owned();
let authed = make_app(store.clone())
.oneshot(Request::post("/mcp").header(header::AUTHORIZATION, format!("Bearer {token}")).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(authed.status(), StatusCode::OK, "a token for this resource must be accepted at /mcp");
store.codes.write().await.insert("proof-code-2".into(), seed_code());
let legacy = make_app(store.clone())
.oneshot(
Request::post("/oauth/token")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body_for("proof-code-2", None)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(legacy.status(), StatusCode::OK, "a missing resource must remain accepted");
}
#[tokio::test]
async fn authorize_strict_requires_resource() {
use axum::extract::{Query, State};
let store = test_store_cfg(true);
store.clients.seed("client-x", vec!["https://claude.ai/api/mcp/auth_callback"]).await;
let mk = |resource: Option<&str>| super::AuthorizeQuery {
response_type: Some("code".into()),
client_id: "client-x".into(),
redirect_uri: "https://claude.ai/api/mcp/auth_callback".into(),
state: Some("xyz".into()),
code_challenge: Some(super::pkce_s256("verifier")),
code_challenge_method: Some("S256".into()),
scope: None,
resource: resource.map(str::to_owned),
};
let missing = super::authorize(State(store.clone()), json_headers(), Query(mk(None))).await;
assert_eq!(missing.status(), axum::http::StatusCode::BAD_REQUEST, "strict mode must refuse a missing resource");
let body = axum::body::to_bytes(missing.into_body(), usize::MAX).await.unwrap();
assert_eq!(serde_json::from_slice::<serde_json::Value>(&body).unwrap()["error"], "invalid_request");
let foreign = super::authorize(State(store.clone()), json_headers(), Query(mk(Some("https://other.example/mcp")))).await;
assert_eq!(foreign.status(), axum::http::StatusCode::BAD_REQUEST);
let body = axum::body::to_bytes(foreign.into_body(), usize::MAX).await.unwrap();
assert_eq!(serde_json::from_slice::<serde_json::Value>(&body).unwrap()["error"], "invalid_target");
let ok = super::authorize(State(store.clone()), axum::http::HeaderMap::new(), Query(mk(Some("https://mcp.test/mcp")))).await;
assert_eq!(ok.status(), axum::http::StatusCode::FOUND, "the canonical resource must still be accepted in strict mode");
}
#[tokio::test]
async fn token_strict_requires_resource() {
use axum::{body::Body, http::{header, Request, StatusCode}, routing::post, Router};
use tower::ServiceExt;
let store = test_store_cfg(true);
let app = || Router::new().route("/oauth/token", post(super::token)).with_state(store.clone());
let seed = || super::CodeGrant {
client_id: "mcp-client".into(),
code_challenge: Some(super::pkce_s256("verifier")),
session_id: "mcp-session".into(),
created: std::time::Instant::now(),
};
let body_for = |code: &str, resource: Option<&str>| {
let mut b = format!("grant_type=authorization_code&code={code}&client_id=mcp-client&code_verifier=verifier");
if let Some(r) = resource {
b.push_str(&format!("&resource={}", urlencoding::encode(r)));
}
b
};
store.codes.write().await.insert("proof-code".into(), seed());
let refused = app()
.oneshot(
Request::post("/oauth/token")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body_for("proof-code", None)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(refused.status(), StatusCode::BAD_REQUEST, "strict mode must refuse a missing resource at /oauth/token");
let body = axum::body::to_bytes(refused.into_body(), usize::MAX).await.unwrap();
assert_eq!(serde_json::from_slice::<serde_json::Value>(&body).unwrap()["error"], "invalid_request");
assert!(store.codes.read().await.contains_key("proof-code"), "a refused request must not consume the code");
let ok = app()
.oneshot(
Request::post("/oauth/token")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body_for("proof-code", Some("https://mcp.test/mcp"))))
.unwrap(),
)
.await
.unwrap();
assert_eq!(ok.status(), StatusCode::OK, "the canonical resource must mint a token in strict mode");
}
#[tokio::test]
async fn not_allowlisted_page_names_contact_and_reflects_nothing() {
let resp = super::not_allowlisted_page();
assert_eq!(resp.status(), axum::http::StatusCode::FORBIDDEN);
let csp = resp
.headers()
.get(axum::http::header::CONTENT_SECURITY_POLICY)
.expect("CSP header present")
.to_str()
.unwrap()
.to_string();
assert!(csp.contains("default-src 'none'"), "{csp}");
assert!(csp.contains("frame-ancestors 'none'"), "{csp}");
assert!(!csp.contains("script-src"), "the error page needs no script-src: {csp}");
let nonce = csp
.split("'nonce-")
.nth(1)
.and_then(|s| s.split('\'').next())
.expect("nonce in CSP")
.to_string();
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(html.contains(super::CONTACT), "the page must name the contact");
assert!(
html.contains(&format!("mailto:{}", super::CONTACT)),
"the contact must be a mailto link"
);
assert!(!html.contains("__"), "every template placeholder must be substituted: {html}");
assert!(!html.contains("<script"), "the error page carries no script");
assert!(
html.contains(&format!("<style nonce=\"{nonce}\">")),
"the inline style nonce must match the CSP nonce"
);
}
#[tokio::test]
async fn authorize_errors_are_friendly_for_browsers_and_json_for_machines() {
use axum::extract::{Query, State};
let store = test_store();
store.clients.seed("client-legacy", vec!["https://example.com/cb"]).await;
let mk = |client_id: &str, redirect_uri: &str| super::AuthorizeQuery {
response_type: Some("code".into()),
client_id: client_id.into(),
redirect_uri: redirect_uri.into(),
state: Some("xyz".into()),
code_challenge: Some("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".into()),
code_challenge_method: Some("S256".into()),
scope: None,
resource: None,
};
let content_type = |resp: &axum::response::Response| {
resp.headers().get(axum::http::header::CONTENT_TYPE).unwrap().to_str().unwrap().to_string()
};
let resp = super::authorize(
State(store.clone()),
html_headers(),
Query(mk("client-legacy", "https://example.com/cb")),
)
.await;
assert_eq!(resp.status(), axum::http::StatusCode::FORBIDDEN);
assert!(content_type(&resp).starts_with("text/html"), "an allow-list rejection renders HTML for a browser");
let html = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap().to_vec(),
)
.unwrap();
assert!(html.contains(super::CONTACT));
let resp = super::authorize(
State(store.clone()),
html_headers(),
Query(mk("client-nope", "https://claude.ai/api/mcp/auth_callback")),
)
.await;
assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST);
assert!(content_type(&resp).starts_with("text/html"), "an unknown client renders HTML for a browser");
let html = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap().to_vec(),
)
.unwrap();
assert!(html.contains(super::CONTACT), "the screen must name the contact");
assert!(html.contains("report it"), "the screen must carry the report-it line");
for (client_id, redirect_uri, want_status) in [
("client-legacy", "https://example.com/cb", axum::http::StatusCode::FORBIDDEN),
("client-nope", "https://claude.ai/api/mcp/auth_callback", axum::http::StatusCode::BAD_REQUEST),
] {
let resp = super::authorize(
State(store.clone()),
json_headers(),
Query(mk(client_id, redirect_uri)),
)
.await;
assert_eq!(resp.status(), want_status);
assert!(content_type(&resp).contains("json"), "a machine caller keeps JSON for {client_id}");
}
}
#[tokio::test]
async fn authorize_malformed_redirect_is_invalid_request_not_allowlist() {
use axum::extract::{Query, State};
let store = test_store();
store.clients.seed("client-x", vec!["http://other.example/cb"]).await;
let mk = || super::AuthorizeQuery {
response_type: Some("code".into()),
client_id: "client-x".into(),
redirect_uri: "http://other.example/cb".into(), state: Some("xyz".into()),
code_challenge: Some("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".into()),
code_challenge_method: Some("S256".into()),
scope: None,
resource: None,
};
let resp = super::authorize(State(store.clone()), html_headers(), Query(mk())).await;
assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST);
let html = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap().to_vec(),
)
.unwrap();
assert!(html.contains("report it"), "shows the generic sign-in error with the report line");
assert!(!html.contains("allow-list"), "must NOT be the allow-list rejection page: {html}");
let resp = super::authorize(State(store.clone()), json_headers(), Query(mk())).await;
assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST);
let body = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap().to_vec(),
)
.unwrap();
assert!(body.contains("invalid_request"), "machine gets invalid_request: {body}");
}
#[tokio::test]
async fn authorize_missing_pkce_is_friendly_for_browsers() {
use axum::extract::{Query, State};
let store = test_store();
store.clients.seed("client-x", vec!["https://claude.ai/api/mcp/auth_callback"]).await;
let mk = || super::AuthorizeQuery {
response_type: Some("code".into()),
client_id: "client-x".into(),
redirect_uri: "https://claude.ai/api/mcp/auth_callback".into(),
state: Some("xyz".into()),
code_challenge: None, code_challenge_method: None,
scope: None,
resource: None,
};
let resp = super::authorize(State(store.clone()), html_headers(), Query(mk())).await;
assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST);
let csp = resp
.headers()
.get(axum::http::header::CONTENT_SECURITY_POLICY)
.expect("CSP header present")
.to_str()
.unwrap()
.to_string();
assert!(csp.contains("default-src 'none'") && csp.contains("frame-ancestors 'none'"), "{csp}");
assert!(!csp.contains("script-src"), "the error screen needs no script-src: {csp}");
let html = String::from_utf8(
axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap().to_vec(),
)
.unwrap();
assert!(html.contains("PKCE"), "the diagnostic should name the missing security parameter");
assert!(html.contains(&format!("mailto:{}", super::CONTACT)), "the screen names the contact");
assert!(!html.contains("__"), "every placeholder must be substituted: {html}");
let resp = super::authorize(State(store.clone()), json_headers(), Query(mk())).await;
assert_eq!(resp.status(), axum::http::StatusCode::BAD_REQUEST);
let ctype =
resp.headers().get(axum::http::header::CONTENT_TYPE).unwrap().to_str().unwrap().to_string();
assert!(ctype.contains("json"), "a machine caller keeps JSON: {ctype}");
}
#[test]
fn accepts_html_detects_browsers_only() {
use axum::http::{header::ACCEPT, HeaderMap, HeaderValue};
let with = |v: &'static str| {
let mut h = HeaderMap::new();
h.insert(ACCEPT, HeaderValue::from_static(v));
h
};
assert!(super::accepts_html(&with("text/html,application/xhtml+xml,*/*")));
assert!(super::accepts_html(&with("text/html")));
assert!(super::accepts_html(&with("text/html;q=0.9,application/json")));
assert!(super::accepts_html(&with("TEXT/HTML")));
assert!(!super::accepts_html(&with("application/json")));
assert!(!super::accepts_html(&with("*/*")));
assert!(!super::accepts_html(&with("text/*")));
assert!(!super::accepts_html(&with("text/html;q=0, application/json")));
assert!(!super::accepts_html(&with("text/html;q=0.0")));
assert!(!super::accepts_html(&HeaderMap::new()));
}
#[tokio::test]
async fn pinned_page_has_strict_csp_matching_nonce_and_no_reflection() {
let resp = super::pinned_callback_page("/mcp-beta");
let csp = resp
.headers()
.get(axum::http::header::CONTENT_SECURITY_POLICY)
.expect("CSP header present")
.to_str()
.unwrap()
.to_string();
assert!(csp.contains("default-src 'none'"), "{csp}");
assert!(csp.contains("connect-src 'self'"), "{csp}");
assert!(csp.contains("frame-ancestors 'none'"), "{csp}");
let nonce = csp
.split("'nonce-")
.nth(1)
.and_then(|s| s.split('\'').next())
.expect("nonce in CSP")
.to_string();
assert!(!nonce.is_empty());
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
let html = String::from_utf8(body.to_vec()).unwrap();
assert!(
html.contains(&format!("<script nonce=\"{nonce}\">")),
"the inline script nonce must match the CSP nonce"
);
assert!(
csp.contains(&format!("style-src 'nonce-{nonce}'")),
"style-src must carry the same nonce: {csp}"
);
assert!(
html.contains(&format!("<style nonce=\"{nonce}\">")),
"the inline style nonce must match the CSP nonce"
);
assert!(html.contains("location.hash"), "the page reads the fragment client-side");
assert!(html.contains("/mcp-beta/oauth/connect/redeem"), "posts to the instance's redeem path");
assert!(!html.contains("__REDEEM_URL__"), "the redeem-URL placeholder must be substituted");
assert!(
html.contains(&format!("mailto:{}", super::CONTACT)),
"the callback page must carry the contact mailto link"
);
assert!(html.contains("contact-hint"), "the contact line uses the .contact-hint hook");
assert!(!html.contains("__CONTACT__"), "the contact placeholder must be substituted");
for param in ["state", "delegation"] {
assert!(
html.contains(&format!("params.get('{param}')")),
"the page must forward `{param}`"
);
}
for param in ["permissions", "ttl", "anchor"] {
assert!(
!html.contains(&format!("params.get('{param}')")),
"the page must NOT read `{param}` from the fragment (merged contract)"
);
}
}
#[test]
fn parse_registration_delegation_round_trips_two_hops() {
let der_preg = vec![1u8, 2, 3];
let der_y = vec![7u8, 7, 7]; let der_x = vec![9u8, 8, 7, 6]; let sig_canister = vec![4u8, 5, 6];
let sig_y = vec![1u8, 9, 9];
let chain_json = serde_json::json!({
"delegations": [
{
"delegation": {
"pubkey": hex::encode(&der_y),
"expiration": format!("{:x}", 66_u64), "targets": ["aaaaa-aa"],
},
"signature": hex::encode(&sig_canister),
},
{
"delegation": {
"pubkey": hex::encode(&der_x),
"expiration": format!("{:x}", 66_u64),
},
"signature": hex::encode(&sig_y),
},
],
"publicKey": hex::encode(&der_preg),
})
.to_string();
let (uk, chain) = super::parse_registration_delegation(&chain_json).expect("parse");
assert_eq!(uk, der_preg);
assert_eq!(chain.len(), 2, "both hops preserved, in order");
assert_eq!(chain[0].delegation.pubkey, der_y);
assert_eq!(chain[0].delegation.expiration, 66);
assert_eq!(chain[0].signature, sig_canister);
assert_eq!(
chain[0].delegation.targets.as_ref().unwrap()[0],
candid::Principal::management_canister()
);
assert_eq!(chain[1].delegation.pubkey, der_x);
assert_eq!(chain[1].signature, sig_y);
assert_eq!(chain[1].delegation.targets, None);
assert!(chain.iter().all(|d| d.delegation.permissions.is_none()));
}
#[test]
fn parse_registration_delegation_rejects_bad_input() {
assert!(super::parse_registration_delegation("not json").is_err());
let bad_hex = serde_json::json!({
"delegations": [{
"delegation": { "pubkey": "zz", "expiration": "1" },
"signature": "0102",
}],
"publicKey": "010203",
})
.to_string();
let err = super::parse_registration_delegation(&bad_hex).expect_err("bad hex must fail");
assert!(err.contains("not valid hex"), "got: {err}");
let bad_exp = serde_json::json!({
"delegations": [{
"delegation": { "pubkey": "0102", "expiration": "not-hex" },
"signature": "0102",
}],
"publicKey": "010203",
})
.to_string();
let err = super::parse_registration_delegation(&bad_exp).expect_err("bad expiration must fail");
assert!(err.contains("expiration"), "got: {err}");
let unknown_field = serde_json::json!({
"delegations": [{
"delegation": { "pubkey": "0102", "expiration": "1", "permissions": "queries" },
"signature": "0102",
}],
"publicKey": "010203",
})
.to_string();
let err = super::parse_registration_delegation(&unknown_field)
.expect_err("an unknown delegation field must fail fast, not silently drop");
assert!(err.contains("permissions"), "got: {err}");
}
#[test]
fn parse_registration_delegation_bounds_input_size() {
let huge = "A".repeat(super::MAX_REG_DELEGATION_JSON + 1);
let err = super::parse_registration_delegation(&huge).expect_err("oversized delegation rejected");
assert!(err.contains("exceeds"), "got: {err}");
let at_cap = "A".repeat(super::MAX_REG_DELEGATION_JSON);
let err = super::parse_registration_delegation(&at_cap).expect_err("fails on content, not size");
assert!(!err.contains("exceeds"), "at-cap input must pass the size check: {err}");
}
#[test]
fn csp_nonce_is_standard_base64() {
for _ in 0..16 {
let n = super::csp_nonce();
assert!(
!n.contains('-') && !n.contains('_'),
"CSP nonce must not use base64url characters: {n}"
);
assert!(n.len() >= 22, "128-bit nonce floor: {n}");
}
}
#[tokio::test]
async fn redemption_claim_is_single_flight() {
let store = test_store();
seed_pending(&store, "sess-r", "bind-r").await;
assert!(matches!(super::claim_redemption(&store, "sess-r").await, super::RedeemClaim::Claimed));
assert!(matches!(
super::claim_redemption(&store, "sess-r").await,
super::RedeemClaim::InProgress
));
super::release_redemption(&store, "sess-r").await;
assert!(matches!(super::claim_redemption(&store, "sess-r").await, super::RedeemClaim::Claimed));
store.authz.write().await.get_mut("sess-r").unwrap().code = Some("mcp-code-x".into());
match super::claim_redemption(&store, "sess-r").await {
super::RedeemClaim::Existing(code) => assert_eq!(code, "mcp-code-x"),
_ => panic!("an existing code must be returned idempotently"),
}
assert!(matches!(
super::claim_redemption(&store, "nope").await,
super::RedeemClaim::Vanished
));
}
#[tokio::test]
async fn connect_routes_are_served() {
use axum::extract::State;
let store = test_store();
let page = super::connect_callback_page(State(store.clone())).await;
assert_eq!(page.status(), axum::http::StatusCode::OK);
assert!(page.headers().contains_key(axum::http::header::CONTENT_SECURITY_POLICY));
let redeem = super::connect_redeem(
State(store),
axum::http::HeaderMap::new(),
axum::Json(super::RedeemBody {
state: "sess-x".into(),
delegation: String::new(),
}),
)
.await;
assert_eq!(redeem.status(), axum::http::StatusCode::BAD_REQUEST);
}
}