Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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());
    }
}