crates/gunnar-mcp/src/oauth.rs
//! **Gunnar as its own OAuth 2.1 authorization server** for the connector.
//!
//! * `GET /.well-known/oauth-protected-resource[/mcp]` — RFC 9728: the
//! resource is `https://<site>/mcp`, its authorization server is the site.
//! * `GET /.well-known/oauth-authorization-server` — RFC 8414 metadata.
//! * `POST /oauth/register` — RFC 7591 dynamic client registration, the way
//! claude.ai custom connectors and `claude mcp add --transport http`
//! register: redirect URIs that are `https://`, or `http://` on loopback
//! (RFC 8252, any port); public clients (`none`) or a client secret.
//! * `GET /oauth/authorize` — the consent page. PKCE with S256 is MANDATORY;
//! `plain` and no challenge are refused. The person touches their passkey
//! there ([`crate::Backend::passkey_finish`], the existing login), may tick
//! the separate write grant, and is sent back with a one-time code
//! (RFC 9207 `iss` included).
//! * `POST /oauth/token` — the code for a bearer token (an hour) and a
//! refresh token that ROTATES on every use; a rotated-past refresh token
//! presented again ends the whole grant.
//! * `POST /oauth/revoke` — RFC 7009: the grant ends, its tokens stop at
//! once and its key is dropped at gunnar.
use std::sync::Arc;
use std::time::{Instant, SystemTime};
use axum::extract::{Query, Request, State};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use base64::Engine as _;
use http::{HeaderMap, StatusCode};
use serde_json::{json, Map, Value};
use crate::tools::{Scope, SCOPE_READ, SCOPE_WRITE};
use crate::{
digest, now_unix, random_token, sanitise_name, too_many, Access, Audit, Backend, Client, Code,
Grant, Mcp, Pending,
};
/// The most bytes a registration, a form or a consent body may carry.
const BODY_MAX: usize = 64 * 1024;
/// The most redirect URIs one client registers.
const REDIRECTS_MAX: usize = 8;
type M<B> = State<Arc<Mcp<B>>>;
fn no_store(mut response: Response) -> Response {
response.headers_mut().insert(
http::header::CACHE_CONTROL,
http::HeaderValue::from_static("no-store"),
);
response.headers_mut().insert(
http::header::PRAGMA,
http::HeaderValue::from_static("no-cache"),
);
response
}
/// An OAuth error body.
pub(crate) fn error(status: StatusCode, code: &str, description: &str) -> Response {
no_store(
(
status,
Json(json!({ "error": code, "error_description": description })),
)
.into_response(),
)
}
/// `GET /.well-known/oauth-protected-resource[/mcp]` (RFC 9728).
pub async fn protected_resource<B: Backend>(State(mcp): M<B>) -> Response {
let c = mcp.config();
Json(json!({
"resource": c.resource(),
"authorization_servers": [c.issuer],
"scopes_supported": [SCOPE_READ, SCOPE_WRITE],
"bearer_methods_supported": ["header"],
"resource_name": "Gunnar",
}))
.into_response()
}
/// `GET /.well-known/oauth-authorization-server` (RFC 8414).
pub async fn authorization_server<B: Backend>(State(mcp): M<B>) -> Response {
let i = &mcp.config().issuer;
Json(json!({
"issuer": i,
"authorization_endpoint": format!("{i}/oauth/authorize"),
"token_endpoint": format!("{i}/oauth/token"),
"registration_endpoint": format!("{i}/oauth/register"),
"revocation_endpoint": format!("{i}/oauth/revoke"),
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
"revocation_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
"scopes_supported": [SCOPE_READ, SCOPE_WRITE],
"authorization_response_iss_parameter_supported": true,
}))
.into_response()
}
/// Is `uri` a redirect URI this server will send a code to? `https://` with a
/// host, or `http://` on loopback (RFC 8252); never a fragment, a user part,
/// whitespace or another scheme.
pub fn valid_redirect(uri: &str) -> bool {
if uri.len() > 2000
|| uri.contains('#')
|| uri.chars().any(|c| c.is_whitespace() || c.is_control())
{
return false;
}
let (secure, rest) = if let Some(r) = uri.strip_prefix("https://") {
(true, r)
} else if let Some(r) = uri.strip_prefix("http://") {
(false, r)
} else {
return false;
};
let authority = rest.split(['/', '?']).next().unwrap_or("");
if authority.is_empty() || authority.contains('@') {
return false;
}
let host = host_of(authority);
if host.is_empty() {
return false;
}
secure || is_loopback(host)
}
fn host_of(authority: &str) -> &str {
if let Some(v6) = authority.strip_prefix('[') {
return v6
.split(']')
.next()
.map(|h| {
if h.is_empty() {
""
} else {
&authority[..h.len() + 2]
}
})
.unwrap_or("");
}
authority.split(':').next().unwrap_or("")
}
fn is_loopback(host: &str) -> bool {
matches!(host, "localhost" | "127.0.0.1" | "[::1]")
}
/// Does `asked` match the registered `uri`? Exactly, except that a loopback
/// redirect may name any port (RFC 8252 §7.3).
pub fn redirect_matches(registered: &str, asked: &str) -> bool {
if registered == asked {
return true;
}
let strip_port = |u: &str| -> Option<String> {
let rest = u.strip_prefix("http://")?;
let (authority, tail) = match rest.find(['/', '?']) {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
let host = host_of(authority);
if !is_loopback(host) {
return None;
}
Some(format!("http://{host}{tail}"))
};
matches!((strip_port(registered), strip_port(asked)), (Some(a), Some(b)) if a == b)
}
async fn body_of(request: Request) -> Result<(http::request::Parts, axum::body::Bytes), Response> {
let (parts, body) = request.into_parts();
match axum::body::to_bytes(body, BODY_MAX).await {
Ok(bytes) => Ok((parts, bytes)),
Err(_) => Err(error(
StatusCode::PAYLOAD_TOO_LARGE,
"invalid_request",
"the body is too large",
)),
}
}
fn strings(v: Option<&Value>) -> Option<Vec<String>> {
v?.as_array()?
.iter()
.map(|s| s.as_str().map(str::to_owned))
.collect()
}
/// `POST /oauth/register` (RFC 7591).
pub async fn register<B: Backend>(State(mcp): M<B>, request: Request) -> Response {
let (parts, body) = match body_of(request).await {
Ok(x) => x,
Err(r) => return r,
};
let client = mcp.backend.client_of(&parts);
if let Err(wait) = mcp.register_admit(&client) {
return too_many(wait);
}
let meta: Map<String, Value> = match serde_json::from_slice::<Value>(&body) {
Ok(Value::Object(m)) => m,
_ => {
return error(
StatusCode::BAD_REQUEST,
"invalid_client_metadata",
"the registration must be a JSON object",
)
}
};
let bad = |what: &str| error(StatusCode::BAD_REQUEST, "invalid_redirect_uri", what);
let Some(redirect_uris) = strings(meta.get("redirect_uris")) else {
return bad("redirect_uris must be a list of URIs");
};
if redirect_uris.is_empty() || redirect_uris.len() > REDIRECTS_MAX {
return bad("register between one and eight redirect URIs");
}
if let Some(wrong) = redirect_uris.iter().find(|u| !valid_redirect(u)) {
let _ = wrong;
return bad("a redirect URI must be https://, or http:// on localhost, with no fragment");
}
let meta_bad = |what: &str| error(StatusCode::BAD_REQUEST, "invalid_client_metadata", what);
let auth_method = match meta.get("token_endpoint_auth_method") {
None => "client_secret_basic".to_owned(),
Some(Value::String(m))
if ["none", "client_secret_post", "client_secret_basic"].contains(&m.as_str()) =>
{
m.clone()
}
Some(_) => return meta_bad(
"token_endpoint_auth_method must be none, client_secret_post or client_secret_basic",
),
};
let grant_types = match meta.get("grant_types") {
None => vec!["authorization_code".to_owned()],
Some(v) => match strings(Some(v)) {
Some(g)
if !g.is_empty()
&& g.iter()
.all(|t| t == "authorization_code" || t == "refresh_token") =>
{
g
}
_ => return meta_bad("grant_types may be authorization_code and refresh_token only"),
},
};
let response_types = match meta.get("response_types") {
None => vec!["code".to_owned()],
Some(v) => match strings(Some(v)) {
Some(r) if r == ["code"] => r,
_ => return meta_bad("response_types must be [\"code\"]"),
},
};
let name = sanitise_name(
meta.get("client_name")
.and_then(Value::as_str)
.unwrap_or("client"),
);
let id = random_token("gmcp_client_");
let secret = (auth_method != "none").then(|| random_token("gmcp_secret_"));
let issued_at = now_unix();
{
let mut st = mcp.lock();
Mcp::<B>::room_for_a_client(&mut st);
st.clients.insert(
id.clone(),
Client {
id: id.clone(),
secret_hash: secret.as_deref().map(digest),
name: name.clone(),
redirect_uris: redirect_uris.clone(),
at: Instant::now(),
},
);
}
let mut answer = json!({
"client_id": id,
"client_id_issued_at": issued_at,
"client_name": name,
"redirect_uris": redirect_uris,
"grant_types": grant_types,
"response_types": response_types,
"token_endpoint_auth_method": auth_method,
"scope": format!("{SCOPE_READ} {SCOPE_WRITE}"),
});
if let Some(secret) = secret {
answer["client_secret"] = json!(secret);
answer["client_secret_expires_at"] = json!(0);
}
no_store((StatusCode::CREATED, Json(answer)).into_response())
}
/// The scope a request asks for: read always, write only when named.
/// `Err` names a scope this server does not have.
fn asked_scope(scope: Option<&str>) -> Result<bool, String> {
let mut write = false;
for word in scope.unwrap_or("").split_whitespace() {
match word {
SCOPE_READ => {}
SCOPE_WRITE => write = true,
other => {
return Err(format!(
"unknown scope {:?}",
crate::tools::said_name(other)
))
}
}
}
Ok(write)
}
fn pkce_challenge_ok(challenge: &str) -> bool {
(43..=128).contains(&challenge.len())
&& challenge
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~'))
}
/// `BASE64URL(SHA256(verifier))`, RFC 7636 S256.
pub fn s256(verifier: &str) -> String {
use sha2::Digest as _;
base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(sha2::Sha256::digest(verifier.as_bytes()))
}
fn with_query(uri: &str, pairs: &[(&str, &str)]) -> String {
let mut out = uri.to_owned();
let mut sep = if uri.contains('?') { '&' } else { '?' };
for (k, v) in pairs {
out.push(sep);
out.push_str(k);
out.push('=');
out.push_str(&encode(v));
sep = '&';
}
out
}
/// Percent-encode a query value (RFC 3986 unreserved kept).
fn encode(v: &str) -> String {
let mut out = String::with_capacity(v.len());
for b in v.bytes() {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
out
}
fn redirect_error(
uri: &str,
state: Option<&str>,
issuer: &str,
code: &str,
description: &str,
) -> Response {
let mut pairs = vec![("error", code), ("error_description", description)];
if let Some(s) = state {
pairs.push(("state", s));
}
pairs.push(("iss", issuer));
no_store(
(
StatusCode::FOUND,
[(http::header::LOCATION, with_query(uri, &pairs))],
)
.into_response(),
)
}
fn page_error(status: StatusCode, why: &str) -> Response {
no_store((status, Html(crate::page::refusal(why))).into_response())
}
/// `GET /oauth/authorize`: the consent page.
pub async fn authorize<B: Backend>(
State(mcp): M<B>,
Query(q): Query<std::collections::HashMap<String, String>>,
request: Request,
) -> Response {
let (parts, _) = request.into_parts();
let client_addr = mcp.backend.client_of(&parts);
if let Err(wait) = mcp.oauth_admit(&client_addr) {
return too_many(wait);
}
mcp.sweep_and_forget().await;
let get = |k: &str| q.get(k).map(String::as_str).filter(|v| !v.is_empty());
let Some(client_id) = get("client_id") else {
return page_error(
StatusCode::BAD_REQUEST,
"This sign-in link names no client. Start the connection again from your app.",
);
};
let Some(client) = mcp.lock().clients.get(client_id).cloned() else {
return page_error(StatusCode::BAD_REQUEST, "This app is not registered here (or its registration has expired). Start the connection again from your app.");
};
let redirect_uri = match get("redirect_uri") {
Some(asked) => match client
.redirect_uris
.iter()
.find(|r| redirect_matches(r, asked))
{
Some(_) => asked.to_owned(),
None => return page_error(
StatusCode::BAD_REQUEST,
"This sign-in link sends you somewhere the app did not register. Nothing was sent.",
),
},
None if client.redirect_uris.len() == 1 => client.redirect_uris[0].clone(),
None => {
return page_error(
StatusCode::BAD_REQUEST,
"This sign-in link names no redirect URI.",
)
}
};
let state = get("state").map(str::to_owned);
let issuer = mcp.config().issuer.clone();
let fail =
|code: &str, why: &str| redirect_error(&redirect_uri, state.as_deref(), &issuer, code, why);
if get("response_type") != Some("code") {
return fail("unsupported_response_type", "only response_type=code");
}
let Some(challenge) = get("code_challenge").filter(|c| pkce_challenge_ok(c)) else {
return fail(
"invalid_request",
"PKCE is required: send code_challenge with code_challenge_method=S256",
);
};
if get("code_challenge_method") != Some("S256") {
return fail(
"invalid_request",
"PKCE is required with code_challenge_method=S256",
);
}
if let Some(resource) = get("resource") {
if resource.trim_end_matches('/') != mcp.config().resource()
&& resource.trim_end_matches('/') != issuer
{
return fail(
"invalid_target",
"this server issues tokens only for its own /mcp",
);
}
}
let wants_write = match asked_scope(get("scope")) {
Ok(w) => w,
Err(why) => return fail("invalid_scope", &why),
};
let request_id = random_token("gmcp_ask_");
{
let mut st = mcp.lock();
Mcp::<B>::room_for_a_page(&mut st);
st.pending.insert(
request_id.clone(),
Pending {
client_id: client.id.clone(),
redirect_uri: redirect_uri.clone(),
state,
challenge: challenge.to_owned(),
wants_write,
at: Instant::now(),
},
);
}
let to = redirect_uri
.split(['?', '#'])
.next()
.unwrap_or("")
.to_owned();
no_store(
Html(crate::page::consent(
&request_id,
&client.name,
&to,
wants_write,
))
.into_response(),
)
}
fn take_pending<B: Backend>(mcp: &Mcp<B>, body: &Value, keep: bool) -> Result<Pending, Response> {
let id = body
.get("request")
.and_then(Value::as_str)
.unwrap_or_default();
let mut st = mcp.lock();
let found = if keep {
st.pending.get(id).cloned()
} else {
st.pending.remove(id)
};
found
.filter(|p| Instant::now().saturating_duration_since(p.at) < mcp.config().consent_ttl)
.ok_or_else(|| {
(
StatusCode::NOT_FOUND,
"This sign-in page has expired. Start the connection again from your app.",
)
.into_response()
})
}
async fn json_body(request: Request) -> Result<(http::request::Parts, Value), Response> {
let (parts, bytes) = body_of(request).await?;
match serde_json::from_slice::<Value>(&bytes) {
Ok(v @ Value::Object(_)) => Ok((parts, v)),
_ => Err((StatusCode::BAD_REQUEST, "the body must be a JSON object").into_response()),
}
}
/// `POST /oauth/authorize/passkey` `{request}`: the touch's options.
pub async fn passkey<B: Backend>(State(mcp): M<B>, request: Request) -> Response {
let (parts, body) = match json_body(request).await {
Ok(x) => x,
Err(r) => return r,
};
let client = mcp.backend.client_of(&parts);
if let Err(wait) = mcp.oauth_admit(&client) {
return too_many(wait);
}
if let Err(r) = take_pending(&mcp, &body, true) {
return r;
}
match mcp.backend.passkey_options(&client).await {
Ok(options) => no_store(Json(options).into_response()),
Err(refusal) => (refusal.status, refusal.message).into_response(),
}
}
/// `POST /oauth/authorize/approve` `{request, write, ceremony_id, id, response}`:
/// gunnar verifies the touch and enrols the agent's key; the browser is sent
/// back with a one-time code.
pub async fn approve<B: Backend>(State(mcp): M<B>, request: Request) -> Response {
let (parts, body) = match json_body(request).await {
Ok(x) => x,
Err(r) => return r,
};
let client_addr = mcp.backend.client_of(&parts);
if let Err(wait) = mcp.oauth_admit(&client_addr) {
return too_many(wait);
}
let pending = match take_pending(&mcp, &body, false) {
Ok(p) => p,
Err(r) => return r,
};
let Some(client) = mcp.lock().clients.get(&pending.client_id).cloned() else {
return (
StatusCode::NOT_FOUND,
"This app's registration has expired. Start the connection again from your app.",
)
.into_response();
};
let expires = SystemTime::now() + mcp.config().grant_ttl;
let expires_ms = i64::try_from(
expires
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or_default(),
)
.unwrap_or(i64::MAX);
let enrolled = match mcp.backend.passkey_finish(&body, expires_ms).await {
Ok(e) => e,
Err(refusal) => return (refusal.status, refusal.message).into_response(),
};
let scope = if pending.wants_write && body.get("write").and_then(Value::as_bool) == Some(true) {
Scope::ReadWrite
} else {
Scope::Read
};
let short: String = client
.id
.trim_start_matches("gmcp_client_")
.chars()
.take(8)
.collect();
let actor = format!("mcp:{}#{short} acting for {}", client.name, enrolled.user);
let code = random_token("gmcp_code_");
let refused = {
let mut st = mcp.lock();
if Mcp::<B>::room_for_a_grant(&st, &enrolled.user) {
let id = st.next_grant;
st.next_grant += 1;
st.grants.insert(
id,
Grant {
id,
client_id: client.id.clone(),
actor: actor.clone(),
user: enrolled.user.clone(),
agent: enrolled.agent.clone(),
fingerprint: enrolled.fingerprint.clone(),
scope,
created_unix: now_unix(),
expires,
bucket: crate::limit::Bucket::new(
mcp.config().calls_burst,
mcp.config().calls_per_second,
),
last_used_unix: None,
refresh: None,
retired: Vec::new(),
},
);
st.codes.insert(
digest(&code),
Code {
grant: id,
client_id: client.id.clone(),
redirect_uri: pending.redirect_uri.clone(),
challenge: pending.challenge.clone(),
at: Instant::now(),
},
);
Ok(id)
} else {
Err(())
}
};
let grant = match refused {
Ok(id) => id,
Err(()) => {
mcp.backend.forget(&enrolled.agent).await;
return (
StatusCode::CONFLICT,
"You hold too many app connections here. End one in your account first.",
)
.into_response();
}
};
mcp.backend.audit(&Audit {
event: "mcp.grant",
actor,
tool: String::new(),
outcome: format!("granted {}", scope.as_str()),
grant,
});
let mut pairs = vec![("code", code.as_str())];
if let Some(s) = pending.state.as_deref() {
pairs.push(("state", s));
}
let issuer = mcp.config().issuer.clone();
pairs.push(("iss", issuer.as_str()));
no_store(Json(json!({ "redirect": with_query(&pending.redirect_uri, &pairs) })).into_response())
}
/// `POST /oauth/authorize/deny` `{request}`.
pub async fn deny<B: Backend>(State(mcp): M<B>, request: Request) -> Response {
let (_, body) = match json_body(request).await {
Ok(x) => x,
Err(r) => return r,
};
let pending = match take_pending(&mcp, &body, false) {
Ok(p) => p,
Err(r) => return r,
};
let mut pairs = vec![("error", "access_denied")];
if let Some(s) = pending.state.as_deref() {
pairs.push(("state", s));
}
let issuer = mcp.config().issuer.clone();
pairs.push(("iss", issuer.as_str()));
no_store(Json(json!({ "redirect": with_query(&pending.redirect_uri, &pairs) })).into_response())
}
/// Decode an `application/x-www-form-urlencoded` body.
fn form(bytes: &[u8]) -> std::collections::HashMap<String, String> {
serde_urlencoded_lite(bytes)
}
fn serde_urlencoded_lite(bytes: &[u8]) -> std::collections::HashMap<String, String> {
let text = String::from_utf8_lossy(bytes);
let mut out = std::collections::HashMap::new();
for pair in text.split('&').filter(|p| !p.is_empty()) {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
out.entry(decode(k)).or_insert_with(|| decode(v));
}
out
}
fn decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => out.push(b' '),
b'%' if i + 2 < bytes.len() => match std::str::from_utf8(&bytes[i + 1..i + 3])
.ok()
.and_then(|h| u8::from_str_radix(h, 16).ok())
{
Some(b) => {
out.push(b);
i += 2;
}
None => out.push(b'%'),
},
b => out.push(b),
}
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
/// Who is calling the token or revoke endpoint: `Ok(client)` or the 401.
fn authenticate<B: Backend>(
mcp: &Mcp<B>,
headers: &HeaderMap,
f: &std::collections::HashMap<String, String>,
) -> Result<Client, Response> {
let basic = headers
.get(http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| {
v.strip_prefix("Basic ")
.or_else(|| v.strip_prefix("basic "))
})
.and_then(|b| {
base64::engine::general_purpose::STANDARD
.decode(b.trim())
.ok()
})
.and_then(|b| String::from_utf8(b).ok())
.and_then(|s| s.split_once(':').map(|(i, p)| (decode(i), decode(p))));
let (id, secret) = match basic {
Some((i, p)) => (i, Some(p)),
None => (
f.get("client_id").cloned().unwrap_or_default(),
f.get("client_secret").cloned(),
),
};
let refused = || {
error(
StatusCode::UNAUTHORIZED,
"invalid_client",
"unknown client or wrong credentials",
)
};
let Some(client) = mcp.lock().clients.get(&id).cloned() else {
return Err(refused());
};
match (&client.secret_hash, secret) {
(None, _) => Ok(client),
(Some(hash), Some(given)) if *hash == digest(&given) => Ok(client),
_ => Err(refused()),
}
}
/// `POST /oauth/token`.
pub async fn token<B: Backend>(State(mcp): M<B>, request: Request) -> Response {
let (parts, bytes) = match body_of(request).await {
Ok(x) => x,
Err(r) => return r,
};
let client_addr = mcp.backend.client_of(&parts);
if let Err(wait) = mcp.oauth_admit(&client_addr) {
return too_many(wait);
}
mcp.sweep_and_forget().await;
let f = form(&bytes);
let client = match authenticate(&mcp, &parts.headers, &f) {
Ok(c) => c,
Err(r) => return r,
};
if let Some(resource) = f.get("resource") {
let r = resource.trim_end_matches('/');
if r != mcp.config().resource() && r != mcp.config().issuer {
return error(
StatusCode::BAD_REQUEST,
"invalid_target",
"this server issues tokens only for its own /mcp",
);
}
}
let invalid = |why: &str| error(StatusCode::BAD_REQUEST, "invalid_grant", why);
match f.get("grant_type").map(String::as_str) {
Some("authorization_code") => {
let code = f.get("code").cloned().unwrap_or_default();
let Some(held) = mcp.lock().codes.remove(&digest(&code)) else {
return invalid("the code is unknown, used or expired");
};
if held.client_id != client.id
|| Instant::now().saturating_duration_since(held.at) >= mcp.config().code_ttl
{
return invalid("the code is unknown, used or expired");
}
match f.get("redirect_uri") {
Some(r) if *r != held.redirect_uri => {
return invalid("redirect_uri differs from the authorization request's")
}
None if client.redirect_uris.len() != 1 => {
return invalid("redirect_uri is required")
}
_ => {}
}
let verifier = f.get("code_verifier").cloned().unwrap_or_default();
if !(43..=128).contains(&verifier.len()) || s256(&verifier) != held.challenge {
return invalid("the PKCE code_verifier does not match");
}
issue(&mcp, held.grant, None)
}
Some("refresh_token") => {
let presented = digest(
f.get("refresh_token")
.map(String::as_str)
.unwrap_or_default(),
);
let live = mcp.lock().refresh.get(&presented).copied();
match live {
Some(grant) => {
let owner = mcp.lock().grants.get(&grant).map(|g| g.client_id.clone());
if owner.as_deref() != Some(client.id.as_str()) {
return invalid("the refresh token is unknown or revoked");
}
issue(&mcp, grant, Some(presented))
}
None => {
// A rotated-past token presented again: somebody holds a
// copy. The grant ends (OAuth 2.1 §4.3.1).
let replayed = mcp
.lock()
.grants
.values()
.find(|g| g.retired.contains(&presented))
.map(|g| g.id);
if let Some(grant) = replayed {
mcp.end_grant(grant, "a used refresh token was presented again")
.await;
}
invalid("the refresh token is unknown or revoked")
}
}
}
_ => error(
StatusCode::BAD_REQUEST,
"unsupported_grant_type",
"authorization_code or refresh_token",
),
}
}
/// Mint an access token (and a fresh refresh token) for `grant`.
fn issue<B: Backend>(mcp: &Mcp<B>, grant: u64, rotating: Option<String>) -> Response {
let access = random_token("gmcp_at_");
let refresh = random_token("gmcp_rt_");
let mut st = mcp.lock();
let Some(g) = st.grants.get_mut(&grant) else {
return error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"this connection has ended",
);
};
let now = SystemTime::now();
if g.expires <= now {
return error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"this connection has ended; connect again",
);
}
let expires = (now + mcp.config.access_ttl).min(g.expires);
let scope = g.scope;
if let Some(old) = rotating {
g.retired.push(old);
if g.retired.len() > 64 {
g.retired.remove(0);
}
}
let previous = g.refresh.replace(digest(&refresh));
if let Some(p) = previous {
st.refresh.remove(&p);
}
st.refresh.insert(digest(&refresh), grant);
st.access.insert(digest(&access), Access { grant, expires });
let expires_in = expires
.duration_since(now)
.map(|d| d.as_secs())
.unwrap_or(0);
no_store(
Json(json!({
"access_token": access,
"token_type": "Bearer",
"expires_in": expires_in,
"refresh_token": refresh,
"scope": scope.as_str(),
}))
.into_response(),
)
}
/// `POST /oauth/revoke` (RFC 7009): 200 whatever the token was.
pub async fn revoke<B: Backend>(State(mcp): M<B>, request: Request) -> Response {
let (parts, bytes) = match body_of(request).await {
Ok(x) => x,
Err(r) => return r,
};
let client_addr = mcp.backend.client_of(&parts);
if let Err(wait) = mcp.oauth_admit(&client_addr) {
return too_many(wait);
}
let f = form(&bytes);
let client = match authenticate(&mcp, &parts.headers, &f) {
Ok(c) => c,
Err(r) => return r,
};
let presented = digest(f.get("token").map(String::as_str).unwrap_or_default());
let grant = {
let st = mcp.lock();
st.access
.get(&presented)
.map(|a| a.grant)
.or_else(|| st.refresh.get(&presented).copied())
};
if let Some(id) = grant {
let owner = mcp.lock().grants.get(&id).map(|g| g.client_id.clone());
if owner.as_deref() == Some(client.id.as_str()) {
mcp.end_grant(id, "revoked by its client").await;
}
}
no_store(StatusCode::OK.into_response())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redirects_are_https_or_loopback_and_nothing_else() {
for ok in [
"https://claude.ai/api/mcp/auth_callback",
"http://localhost:33418/callback",
"http://127.0.0.1:5000/cb",
"http://[::1]:8080/cb",
"https://x.mock.invalid/cb?a=b",
] {
assert!(valid_redirect(ok), "{ok}");
}
for bad in [
"http://claude.ai/cb",
"javascript:alert(1)",
"https://claude.ai/cb#frag",
"https://user@evil.invalid/cb",
"ftp://x/y",
"https:///nohost",
"cursor://anything",
"http://localhost.evil.invalid/cb",
" https://claude.ai/cb",
] {
assert!(!valid_redirect(bad), "{bad}");
}
}
#[test]
fn a_loopback_redirect_may_change_its_port_and_nothing_else_may_change() {
assert!(redirect_matches(
"http://localhost:1/callback",
"http://localhost:4242/callback"
));
assert!(redirect_matches(
"http://127.0.0.1/callback",
"http://127.0.0.1:9/callback"
));
assert!(!redirect_matches(
"http://localhost:1/callback",
"http://localhost:4242/other"
));
assert!(!redirect_matches(
"https://claude.ai/api/mcp/auth_callback",
"https://claude.ai:444/api/mcp/auth_callback"
));
assert!(!redirect_matches(
"https://claude.ai/a",
"https://claude.ai/a/b"
));
}
#[test]
fn s256_is_unpadded_base64url_of_sha256() {
// Computed independently (Python hashlib + base64.urlsafe_b64encode).
assert_eq!(
s256("a-test-verifier-that-is-long-enough-for-rfc-7636-yes"),
"U3E_4XkQGLNQJDTZi6wTmu4pkC4Kb-f23x9RLsh1h3w"
);
}
#[test]
fn a_form_decodes() {
let f = form(b"grant_type=authorization_code&code=a%2Bb&redirect_uri=http%3A%2F%2Flocalhost%3A1%2Fcb&x=1+2");
assert_eq!(f["grant_type"], "authorization_code");
assert_eq!(f["code"], "a+b");
assert_eq!(f["redirect_uri"], "http://localhost:1/cb");
assert_eq!(f["x"], "1 2");
assert_eq!(decode("100%"), "100%");
assert_eq!(decode("%zz"), "%zz");
}
#[test]
fn the_query_is_percent_encoded() {
assert_eq!(
with_query("https://a.invalid/cb", &[("code", "x y"), ("state", "s&t")]),
"https://a.invalid/cb?code=x%20y&state=s%26t"
);
assert_eq!(
with_query("https://a.invalid/cb?k=1", &[("code", "c")]),
"https://a.invalid/cb?k=1&code=c"
);
}
#[test]
fn only_the_two_scopes_exist() {
assert_eq!(asked_scope(None), Ok(false));
assert_eq!(asked_scope(Some("gunnar:read")), Ok(false));
assert_eq!(asked_scope(Some("gunnar:read gunnar:write")), Ok(true));
assert!(asked_scope(Some("admin")).is_err());
}
}