Skip to main content

things_mcp/
oauth.rs

1//! OAuth 2.1 authorization surface for the streamable-HTTP transport.
2//!
3//! Claude.ai's MCP connector follows the spec-canonical flow: authorization
4//! code with PKCE (RFC 7636, S256). Even though the connector UI exposes
5//! "Client ID" / "Client Secret" fields, the actual grant is
6//! `authorization_code` — the secret is just an additional bearer of trust on
7//! the token request. The flow we observed in practice:
8//!
9//!   1. Client (Anthropic backend) hits `/mcp` without a token →
10//!      `401 Unauthorized` with a `WWW-Authenticate: Bearer
11//!      resource_metadata="…"` challenge (RFC 9728 §5.1).
12//!   2. Client fetches `/.well-known/oauth-protected-resource` and
13//!      `/.well-known/oauth-authorization-server` to discover this endpoint set.
14//!   3. The user's browser is opened to `/authorize?response_type=code&…&
15//!      code_challenge=…&code_challenge_method=S256&state=…&redirect_uri=
16//!      https://claude.ai/api/mcp/auth_callback`.
17//!   4. We 302-redirect to that `redirect_uri` with a one-time code + state.
18//!   5. Claude.ai's backend posts `grant_type=authorization_code` to
19//!      `/oauth/token` with the code + `code_verifier`. We verify
20//!      `SHA256(code_verifier)` (base64url, no pad) matches the stored
21//!      `code_challenge` and mint an opaque access token.
22//!
23//! Both grant types — `authorization_code` and `client_credentials` — are
24//! supported. The latter is retained for headless scripting and tests.
25
26mod token_store;
27pub use token_store::{ChainId, MintedPair, RefreshError, TokenStore};
28
29use std::collections::HashMap;
30use std::path::PathBuf;
31use std::sync::Arc;
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use axum::{
35    extract::{Query, State},
36    http::{header, HeaderMap, StatusCode},
37    response::IntoResponse,
38    routing::{get, post},
39    Form, Json, Router,
40};
41use base64::Engine;
42use serde::{Deserialize, Serialize};
43use sha2::{Digest, Sha256};
44use tokio::sync::RwLock;
45
46const AUTH_CODE_TTL_SECS: u64 = 300;
47
48/// Redirect URIs we'll accept on the authorization endpoint. The OAuth 2.1
49/// spec requires exact-match validation against pre-registered values — for a
50/// single-purpose connector talking only to Claude.ai we hardcode the two
51/// hostnames Anthropic actually uses.
52const ALLOWED_REDIRECT_URI_PREFIXES: &[&str] =
53    &["https://claude.ai/api/mcp/", "https://claude.com/api/mcp/"];
54
55pub const DEFAULT_ACCESS_TOKEN_TTL_SECS: u64 = 7 * 24 * 3600; // 7 days
56pub const DEFAULT_REFRESH_TOKEN_TTL_SECS: u64 = 90 * 24 * 3600; // 90 days
57
58/// Pre-shared OAuth client credentials. Persisted at
59/// `<config_dir>/oauth.toml` with mode 0600 so the secret never lands in a
60/// world-readable location.
61#[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct OAuthConfig {
63    pub client_id: String,
64    pub client_secret: String,
65    /// Public origin advertised in discovery + used to compute the resource
66    /// metadata URL in 401 challenges. Must match what the client believes the
67    /// canonical URI is (e.g. the Tailscale Funnel hostname).
68    pub issuer: String,
69    #[serde(default)]
70    pub access_token_ttl_secs: Option<u64>,
71    #[serde(default)]
72    pub refresh_token_ttl_secs: Option<u64>,
73}
74
75impl OAuthConfig {
76    pub fn effective_access_ttl(&self) -> std::time::Duration {
77        std::time::Duration::from_secs(
78            self.access_token_ttl_secs
79                .unwrap_or(DEFAULT_ACCESS_TOKEN_TTL_SECS),
80        )
81    }
82    pub fn effective_refresh_ttl(&self) -> std::time::Duration {
83        std::time::Duration::from_secs(
84            self.refresh_token_ttl_secs
85                .unwrap_or(DEFAULT_REFRESH_TOKEN_TTL_SECS),
86        )
87    }
88}
89
90/// Location of the on-disk OAuth config. Uses the same ProjectDirs convention
91/// as `things-mcp`'s config path so users find both files in the same
92/// directory (`~/Library/Application Support/dev.things-mcp.things-mcp` on
93/// macOS, `~/.config/things-mcp` on Linux).
94pub fn config_path() -> Option<PathBuf> {
95    directories::ProjectDirs::from("dev", "things-mcp", "things-mcp")
96        .map(|d| d.config_dir().join("oauth.toml"))
97}
98
99impl OAuthConfig {
100    /// Resolve credentials. Precedence:
101    /// 1. If `<config_dir>/oauth.toml` exists, load it.
102    /// 2. Otherwise, if `issuer_hint` is `Some`, generate a fresh credential
103    ///    pair (random client_id + 32-byte hex client_secret), persist it
104    ///    with mode 0600, and return it. The generated values are logged to
105    ///    stderr so the user can paste them into the Claude.ai connector.
106    /// 3. Otherwise, return `Ok(None)` — OAuth is opt-in; without an issuer
107    ///    we cannot generate a sensible config.
108    pub fn load_or_generate(issuer_hint: Option<String>) -> anyhow::Result<Option<Self>> {
109        let Some(path) = config_path() else {
110            tracing::warn!("could not resolve ProjectDirs for OAuth config; OAuth disabled");
111            return Ok(None);
112        };
113
114        if path.exists() {
115            let bytes = std::fs::read(&path)
116                .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
117            let config: OAuthConfig = toml::from_str(std::str::from_utf8(&bytes)?)
118                .map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))?;
119            tracing::info!(path = %path.display(), "loaded OAuth config");
120            return Ok(Some(config));
121        }
122
123        let Some(issuer) = issuer_hint else {
124            tracing::warn!(
125                path = %path.display(),
126                "OAuth config not found and no THINGS_MCP_OAUTH_ISSUER set; OAuth disabled"
127            );
128            return Ok(None);
129        };
130
131        let config = OAuthConfig {
132            client_id: format!("things-mcp-{}", short_id()),
133            client_secret: format!("{:032x}", rand::random::<u128>()),
134            issuer,
135            access_token_ttl_secs: None,
136            refresh_token_ttl_secs: None,
137        };
138        Self::write_secure(&path, &config)?;
139        tracing::warn!(
140            path = %path.display(),
141            client_id = %config.client_id,
142            "generated OAuth credentials — paste these into the Claude.ai connector's Advanced fields"
143        );
144        // Also print to stderr so the message is visible on the very first run
145        // even if the logger threshold filters out warnings.
146        eprintln!(
147            "\n=== things-mcp OAuth credentials generated at {} ===\n  client_id     = {}\n  client_secret = {}\n  issuer        = {}\n  → paste client_id + client_secret into Claude.ai connector → Advanced → OAuth fields\n",
148            path.display(),
149            config.client_id,
150            config.client_secret,
151            config.issuer,
152        );
153        Ok(Some(config))
154    }
155
156    fn write_secure(path: &std::path::Path, config: &OAuthConfig) -> anyhow::Result<()> {
157        if let Some(parent) = path.parent() {
158            std::fs::create_dir_all(parent)
159                .map_err(|e| anyhow::anyhow!("mkdir {}: {e}", parent.display()))?;
160        }
161        let serialized = toml::to_string_pretty(config)?;
162        std::fs::write(path, serialized)
163            .map_err(|e| anyhow::anyhow!("write {}: {e}", path.display()))?;
164        #[cfg(unix)]
165        {
166            use std::os::unix::fs::PermissionsExt;
167            let perms = std::fs::Permissions::from_mode(0o600);
168            std::fs::set_permissions(path, perms)
169                .map_err(|e| anyhow::anyhow!("chmod 0600 {}: {e}", path.display()))?;
170        }
171        Ok(())
172    }
173}
174
175fn short_id() -> String {
176    format!("{:08x}", rand::random::<u32>())
177}
178
179/// Shared runtime state for the OAuth surface. Cheaply cloneable.
180#[derive(Clone)]
181pub struct OAuthState {
182    inner: Arc<Inner>,
183}
184
185struct Inner {
186    config: OAuthConfig,
187    /// In-memory authorization-code store. Codes are single-use, 5-minute TTL —
188    /// surviving a server restart is not a goal for this short-lived state.
189    codes: RwLock<HashMap<String, AuthCode>>,
190    tokens: TokenStore,
191}
192
193#[derive(Clone)]
194struct AuthCode {
195    code_challenge: String,
196    redirect_uri: String,
197    expires_at: u64,
198}
199
200impl OAuthState {
201    /// Construct an OAuthState backed by a TokenStore at `tokens_path`.
202    /// Use `OAuthState::with_tokens_path` to supply the path explicitly,
203    /// or `OAuthState::from_default_path` to derive it from the standard
204    /// ProjectDirs location (test code uses the former, production uses the latter).
205    pub fn with_tokens_path(config: OAuthConfig, tokens_path: PathBuf) -> anyhow::Result<Self> {
206        let access_ttl = config.effective_access_ttl();
207        let refresh_ttl = config.effective_refresh_ttl();
208        let tokens = TokenStore::load(tokens_path, &config.client_id, access_ttl, refresh_ttl)?;
209        Ok(Self {
210            inner: Arc::new(Inner {
211                config,
212                codes: RwLock::new(HashMap::new()),
213                tokens,
214            }),
215        })
216    }
217
218    /// Standard production constructor: derive the tokens path from the
219    /// same ProjectDirs base used by `oauth.toml`.
220    pub fn from_default_path(config: OAuthConfig) -> anyhow::Result<Self> {
221        let dir = directories::ProjectDirs::from("dev", "things-mcp", "things-mcp")
222            .ok_or_else(|| anyhow::anyhow!("could not resolve ProjectDirs for tokens.json"))?
223            .config_dir()
224            .to_path_buf();
225        Self::with_tokens_path(config, dir.join("tokens.json"))
226    }
227
228    pub fn issuer(&self) -> &str {
229        &self.inner.config.issuer
230    }
231
232    /// URL the WWW-Authenticate challenge points clients at for resource
233    /// metadata. Defined by RFC 9728 §5.1.
234    pub fn resource_metadata_url(&self) -> String {
235        format!(
236            "{}/.well-known/oauth-protected-resource",
237            self.inner.config.issuer
238        )
239    }
240
241    /// Returns true iff the token was previously issued and is not expired.
242    pub async fn validate_token(&self, token: &str) -> bool {
243        self.inner.tokens.validate_access(token).await
244    }
245
246    /// Crate-internal access to the token store — used by tests in sibling
247    /// modules that need to mint a token without going through the HTTP layer.
248    #[cfg(test)]
249    pub(crate) fn token_store(&self) -> &TokenStore {
250        &self.inner.tokens
251    }
252}
253
254fn unix_now() -> u64 {
255    SystemTime::now()
256        .duration_since(UNIX_EPOCH)
257        .unwrap_or_default()
258        .as_secs()
259}
260
261#[derive(Serialize)]
262struct AuthorizationServerMetadata {
263    issuer: String,
264    authorization_endpoint: String,
265    token_endpoint: String,
266    grant_types_supported: &'static [&'static str],
267    token_endpoint_auth_methods_supported: &'static [&'static str],
268    response_types_supported: &'static [&'static str],
269    code_challenge_methods_supported: &'static [&'static str],
270    scopes_supported: &'static [&'static str],
271}
272
273#[derive(Serialize)]
274struct ProtectedResourceMetadata {
275    resource: String,
276    authorization_servers: Vec<String>,
277    bearer_methods_supported: &'static [&'static str],
278    scopes_supported: &'static [&'static str],
279}
280
281async fn authorization_server_metadata(
282    State(state): State<OAuthState>,
283) -> Json<AuthorizationServerMetadata> {
284    let issuer = state.issuer().to_string();
285    Json(AuthorizationServerMetadata {
286        authorization_endpoint: format!("{issuer}/authorize"),
287        token_endpoint: format!("{issuer}/oauth/token"),
288        issuer,
289        grant_types_supported: &["authorization_code", "refresh_token", "client_credentials"],
290        token_endpoint_auth_methods_supported: &["client_secret_post", "client_secret_basic"],
291        response_types_supported: &["code", "token"],
292        code_challenge_methods_supported: &["S256"],
293        scopes_supported: &["mcp"],
294    })
295}
296
297async fn protected_resource_metadata(
298    State(state): State<OAuthState>,
299) -> Json<ProtectedResourceMetadata> {
300    let issuer = state.issuer().to_string();
301    Json(ProtectedResourceMetadata {
302        authorization_servers: vec![issuer.clone()],
303        resource: issuer,
304        bearer_methods_supported: &["header"],
305        scopes_supported: &["mcp"],
306    })
307}
308
309#[derive(Deserialize)]
310struct TokenRequest {
311    grant_type: String,
312    // client_credentials inputs
313    client_id: Option<String>,
314    client_secret: Option<String>,
315    // authorization_code inputs
316    #[serde(default)]
317    code: Option<String>,
318    #[serde(default)]
319    code_verifier: Option<String>,
320    #[serde(default)]
321    redirect_uri: Option<String>,
322    /// RFC 8707 Resource Indicator. Accepted but not enforced — single-resource
323    /// server.
324    #[allow(dead_code)]
325    #[serde(default)]
326    resource: Option<String>,
327    #[allow(dead_code)]
328    #[serde(default)]
329    scope: Option<String>,
330    #[serde(default)]
331    refresh_token: Option<String>,
332}
333
334#[derive(Serialize)]
335struct TokenResponse {
336    access_token: String,
337    token_type: &'static str,
338    expires_in: u64,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    refresh_token: Option<String>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    refresh_expires_in: Option<u64>,
343    scope: &'static str,
344}
345
346#[derive(Serialize)]
347struct OAuthError {
348    error: &'static str,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    error_description: Option<&'static str>,
351}
352
353async fn token_handler(
354    State(state): State<OAuthState>,
355    headers: HeaderMap,
356    Form(body): Form<TokenRequest>,
357) -> axum::response::Response {
358    match body.grant_type.as_str() {
359        "authorization_code" => handle_authorization_code(state, headers, body).await,
360        "client_credentials" => handle_client_credentials(state, headers, body).await,
361        "refresh_token" => handle_refresh_token(state, headers, body).await,
362        _ => (
363            StatusCode::BAD_REQUEST,
364            Json(OAuthError {
365                error: "unsupported_grant_type",
366                error_description: Some(
367                    "supported grant types: authorization_code, refresh_token, client_credentials",
368                ),
369            }),
370        )
371            .into_response(),
372    }
373}
374
375async fn handle_client_credentials(
376    state: OAuthState,
377    headers: HeaderMap,
378    body: TokenRequest,
379) -> axum::response::Response {
380    let Some((client_id, client_secret)) = resolve_client_credentials(&headers, &body) else {
381        return invalid_client();
382    };
383
384    let expected = &state.inner.config;
385    if !constant_time_eq(client_id.as_bytes(), expected.client_id.as_bytes())
386        || !constant_time_eq(client_secret.as_bytes(), expected.client_secret.as_bytes())
387    {
388        return invalid_client();
389    }
390
391    let pair = match state.inner.tokens.mint_pair(None).await {
392        Ok(p) => p,
393        Err(e) => {
394            tracing::error!(error = %e, "mint_pair failed for client_credentials");
395            return server_error();
396        }
397    };
398    let token = pair.access_token;
399    let ttl = pair.access_ttl.as_secs();
400    tracing::info!(
401        client_id = %client_id,
402        grant = "client_credentials",
403        expires_in = ttl,
404        "OAuth token minted"
405    );
406    token_ok_access_only(token, ttl)
407}
408
409async fn handle_authorization_code(
410    state: OAuthState,
411    headers: HeaderMap,
412    body: TokenRequest,
413) -> axum::response::Response {
414    let Some(code) = body.code.as_deref() else {
415        return invalid_grant("missing code");
416    };
417    let Some(verifier) = body.code_verifier.as_deref() else {
418        return invalid_grant("missing code_verifier");
419    };
420    let Some(redirect_uri) = body.redirect_uri.as_deref() else {
421        return invalid_grant("missing redirect_uri");
422    };
423
424    // Client authentication is optional with PKCE per RFC 6749 §4.1.3 (the
425    // code_verifier is proof of possession) but if the caller did present
426    // credentials, validate them — Claude.ai may send the client_secret it
427    // was given.
428    if let Some((client_id, client_secret)) = resolve_client_credentials(&headers, &body) {
429        let expected = &state.inner.config;
430        if !constant_time_eq(client_id.as_bytes(), expected.client_id.as_bytes())
431            || !constant_time_eq(client_secret.as_bytes(), expected.client_secret.as_bytes())
432        {
433            return invalid_client();
434        }
435    } else if let Some(client_id) = body.client_id.as_deref() {
436        // client_id alone (public client) — must still match.
437        if !constant_time_eq(
438            client_id.as_bytes(),
439            state.inner.config.client_id.as_bytes(),
440        ) {
441            return invalid_client();
442        }
443    }
444
445    let info = state.inner.codes.write().await.remove(code);
446    let Some(info) = info else {
447        return invalid_grant("unknown or already-used code");
448    };
449    if info.expires_at < unix_now() {
450        return invalid_grant("code expired");
451    }
452    if info.redirect_uri != redirect_uri {
453        return invalid_grant("redirect_uri mismatch");
454    }
455    let computed = pkce_s256(verifier);
456    if !constant_time_eq(computed.as_bytes(), info.code_challenge.as_bytes()) {
457        return invalid_grant("PKCE verification failed");
458    }
459
460    let pair = match state.inner.tokens.mint_pair(None).await {
461        Ok(p) => p,
462        Err(e) => {
463            tracing::error!(error = %e, "mint_pair failed for authorization_code");
464            return server_error();
465        }
466    };
467    tracing::info!(
468        grant = "authorization_code",
469        chain_id = %pair.chain_id,
470        expires_in = pair.access_ttl.as_secs(),
471        "OAuth token pair minted"
472    );
473    token_ok_pair(pair)
474}
475
476async fn handle_refresh_token(
477    state: OAuthState,
478    headers: HeaderMap,
479    body: TokenRequest,
480) -> axum::response::Response {
481    // Optional client authentication — same logic as handle_authorization_code.
482    if let Some((client_id, client_secret)) = resolve_client_credentials(&headers, &body) {
483        let expected = &state.inner.config;
484        if !constant_time_eq(client_id.as_bytes(), expected.client_id.as_bytes())
485            || !constant_time_eq(client_secret.as_bytes(), expected.client_secret.as_bytes())
486        {
487            return invalid_client();
488        }
489    } else if let Some(client_id) = body.client_id.as_deref() {
490        if !constant_time_eq(
491            client_id.as_bytes(),
492            state.inner.config.client_id.as_bytes(),
493        ) {
494            return invalid_client();
495        }
496    }
497
498    let Some(presented) = body.refresh_token.as_deref() else {
499        return invalid_grant("missing refresh_token");
500    };
501
502    let chain_id = match state.inner.tokens.consume_refresh(presented).await {
503        Ok(chain) => chain,
504        Err(RefreshError::Replayed(chain)) => {
505            tracing::warn!(chain_id = %chain, "refresh-token replay detected; revoking chain");
506            state.inner.tokens.revoke_chain(chain).await;
507            return invalid_grant("refresh token replay");
508        }
509        Err(RefreshError::Expired) => return invalid_grant("refresh token expired"),
510        Err(RefreshError::Unknown) => return invalid_grant("unknown refresh token"),
511    };
512
513    let pair = match state.inner.tokens.mint_pair(Some(chain_id.clone())).await {
514        Ok(p) => p,
515        Err(e) => {
516            tracing::error!(error = %e, "mint_pair failed during refresh_token grant");
517            return server_error();
518        }
519    };
520    tracing::info!(
521        grant = "refresh_token",
522        chain_id = %chain_id,
523        expires_in = pair.access_ttl.as_secs(),
524        "OAuth token pair minted (refreshed)"
525    );
526    token_ok_pair(pair)
527}
528
529fn token_ok_access_only(token: String, ttl: u64) -> axum::response::Response {
530    (
531        StatusCode::OK,
532        Json(TokenResponse {
533            access_token: token,
534            token_type: "Bearer",
535            expires_in: ttl,
536            refresh_token: None,
537            refresh_expires_in: None,
538            scope: "mcp",
539        }),
540    )
541        .into_response()
542}
543
544fn token_ok_pair(pair: MintedPair) -> axum::response::Response {
545    (
546        StatusCode::OK,
547        Json(TokenResponse {
548            access_token: pair.access_token,
549            token_type: "Bearer",
550            expires_in: pair.access_ttl.as_secs(),
551            refresh_token: Some(pair.refresh_token),
552            refresh_expires_in: Some(pair.refresh_ttl.as_secs()),
553            scope: "mcp",
554        }),
555    )
556        .into_response()
557}
558
559fn invalid_grant(detail: &'static str) -> axum::response::Response {
560    tracing::info!(detail, "OAuth grant rejected");
561    (
562        StatusCode::BAD_REQUEST,
563        Json(OAuthError {
564            error: "invalid_grant",
565            error_description: Some(detail),
566        }),
567    )
568        .into_response()
569}
570
571/// `BASE64URL(SHA256(verifier))` with no padding, per RFC 7636 §4.6.
572fn pkce_s256(verifier: &str) -> String {
573    let digest = Sha256::digest(verifier.as_bytes());
574    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest)
575}
576
577#[derive(Deserialize)]
578struct AuthorizeQuery {
579    response_type: String,
580    client_id: String,
581    redirect_uri: String,
582    code_challenge: String,
583    code_challenge_method: String,
584    state: String,
585    #[allow(dead_code)]
586    #[serde(default)]
587    scope: Option<String>,
588    #[allow(dead_code)]
589    #[serde(default)]
590    resource: Option<String>,
591}
592
593async fn authorize_handler(
594    State(state): State<OAuthState>,
595    Query(q): Query<AuthorizeQuery>,
596) -> axum::response::Response {
597    // We validate the redirect_uri BEFORE responding via redirect — sending
598    // any params to an unvetted URI would be an open-redirect bug.
599    let redirect_ok = ALLOWED_REDIRECT_URI_PREFIXES
600        .iter()
601        .any(|p| q.redirect_uri.starts_with(p));
602    if !redirect_ok {
603        tracing::warn!(redirect_uri = %q.redirect_uri, "authorize: redirect_uri not allowed");
604        return (StatusCode::BAD_REQUEST, "invalid_redirect_uri").into_response();
605    }
606    if q.response_type != "code" {
607        return redirect_with_error(&q.redirect_uri, &q.state, "unsupported_response_type");
608    }
609    if q.code_challenge_method != "S256" {
610        return redirect_with_error(&q.redirect_uri, &q.state, "invalid_request");
611    }
612    if !constant_time_eq(
613        q.client_id.as_bytes(),
614        state.inner.config.client_id.as_bytes(),
615    ) {
616        return redirect_with_error(&q.redirect_uri, &q.state, "unauthorized_client");
617    }
618    if q.code_challenge.is_empty() {
619        return redirect_with_error(&q.redirect_uri, &q.state, "invalid_request");
620    }
621
622    let code = format!("{:032x}", rand::random::<u128>());
623    let info = AuthCode {
624        code_challenge: q.code_challenge,
625        redirect_uri: q.redirect_uri.clone(),
626        expires_at: unix_now() + AUTH_CODE_TTL_SECS,
627    };
628    state.inner.codes.write().await.insert(code.clone(), info);
629    tracing::info!(redirect_uri = %q.redirect_uri, "authorization code issued");
630
631    let location = format!(
632        "{}?code={}&state={}",
633        q.redirect_uri,
634        urlencoding_minimal(&code),
635        urlencoding_minimal(&q.state),
636    );
637    (StatusCode::FOUND, [(header::LOCATION, location.as_str())]).into_response()
638}
639
640fn redirect_with_error(redirect_uri: &str, state: &str, error: &str) -> axum::response::Response {
641    let location = format!(
642        "{redirect_uri}?error={}&state={}",
643        urlencoding_minimal(error),
644        urlencoding_minimal(state)
645    );
646    (StatusCode::FOUND, [(header::LOCATION, location.as_str())]).into_response()
647}
648
649/// Minimal URL-encoding for the small set of characters that appear in our
650/// outputs (state tokens are alphanumeric+`_-`, codes are hex). Reaching for
651/// the `urlencoding` crate just to encode `&`, `=`, `+`, ` ` would be
652/// disproportionate.
653fn urlencoding_minimal(s: &str) -> String {
654    let mut out = String::with_capacity(s.len());
655    for c in s.chars() {
656        match c {
657            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => out.push(c),
658            _ => out.push_str(&format!("%{:02X}", c as u32)),
659        }
660    }
661    out
662}
663
664/// Pull `client_id`/`client_secret` from the form body (client_secret_post)
665/// or fall back to the `Authorization: Basic …` header (client_secret_basic).
666fn resolve_client_credentials(
667    headers: &HeaderMap,
668    body: &TokenRequest,
669) -> Option<(String, String)> {
670    if let (Some(id), Some(secret)) = (body.client_id.as_ref(), body.client_secret.as_ref()) {
671        return Some((id.clone(), secret.clone()));
672    }
673    let auth = headers.get(header::AUTHORIZATION)?.to_str().ok()?;
674    let encoded = auth.strip_prefix("Basic ")?;
675    let bytes = base64::engine::general_purpose::STANDARD
676        .decode(encoded.trim())
677        .ok()?;
678    let decoded = String::from_utf8(bytes).ok()?;
679    let (id, secret) = decoded.split_once(':')?;
680    Some((id.to_string(), secret.to_string()))
681}
682
683fn invalid_client() -> axum::response::Response {
684    (
685        StatusCode::UNAUTHORIZED,
686        [(header::WWW_AUTHENTICATE, "Basic realm=\"oauth/token\"")],
687        Json(OAuthError {
688            error: "invalid_client",
689            error_description: None,
690        }),
691    )
692        .into_response()
693}
694
695fn server_error() -> axum::response::Response {
696    (
697        StatusCode::INTERNAL_SERVER_ERROR,
698        Json(OAuthError {
699            error: "server_error",
700            error_description: None,
701        }),
702    )
703        .into_response()
704}
705
706/// Length-aware equality that avoids byte-by-byte short-circuit. For
707/// fixed-length pre-shared secrets the length disclosure is irrelevant.
708fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
709    if a.len() != b.len() {
710        return false;
711    }
712    let mut diff: u8 = 0;
713    for (x, y) in a.iter().zip(b.iter()) {
714        diff |= x ^ y;
715    }
716    diff == 0
717}
718
719/// Build the public, unauthenticated OAuth surface.
720///
721/// Routes:
722///   - `GET /.well-known/oauth-authorization-server` — RFC 8414 metadata
723///   - `GET /.well-known/openid-configuration`        — alias of the above, for
724///     OIDC-flavored clients (notably Claude.ai's connector) that probe the
725///     OIDC discovery suffix when the issuer URL has a path component
726///   - `GET /.well-known/oauth-protected-resource`    — RFC 9728 metadata
727///   - `GET /authorize`                               — auth-code start (PKCE)
728///   - `POST /oauth/token`                            — token issuance
729pub fn router(state: OAuthState) -> Router {
730    Router::new()
731        .route(
732            "/.well-known/oauth-authorization-server",
733            get(authorization_server_metadata),
734        )
735        .route(
736            "/.well-known/openid-configuration",
737            get(authorization_server_metadata),
738        )
739        .route(
740            "/.well-known/oauth-protected-resource",
741            get(protected_resource_metadata),
742        )
743        .route("/authorize", get(authorize_handler))
744        .route("/oauth/token", post(token_handler))
745        .with_state(state)
746}
747
748#[cfg(test)]
749mod tests {
750    use super::*;
751    use axum::body::{to_bytes, Body};
752    use axum::http::Request;
753    use tower::ServiceExt;
754
755    #[test]
756    fn config_loads_with_default_ttls_when_unset() {
757        let toml_str = r#"
758            client_id = "x"
759            client_secret = "y"
760            issuer = "https://example.test"
761        "#;
762        let cfg: OAuthConfig = toml::from_str(toml_str).unwrap();
763        assert_eq!(cfg.access_token_ttl_secs, None);
764        assert_eq!(cfg.refresh_token_ttl_secs, None);
765        assert_eq!(cfg.effective_access_ttl().as_secs(), 7 * 24 * 3600);
766        assert_eq!(cfg.effective_refresh_ttl().as_secs(), 90 * 24 * 3600);
767    }
768
769    #[test]
770    fn config_loads_with_explicit_ttls() {
771        let toml_str = r#"
772            client_id = "x"
773            client_secret = "y"
774            issuer = "https://example.test"
775            access_token_ttl_secs = 3600
776            refresh_token_ttl_secs = 86400
777        "#;
778        let cfg: OAuthConfig = toml::from_str(toml_str).unwrap();
779        assert_eq!(cfg.effective_access_ttl().as_secs(), 3600);
780        assert_eq!(cfg.effective_refresh_ttl().as_secs(), 86400);
781    }
782
783    #[test]
784    fn config_roundtrips_through_disk_with_secure_perms() {
785        let dir = tempdir();
786        let path = dir.join("oauth.toml");
787        let original = OAuthConfig {
788            client_id: "id-x".into(),
789            client_secret: "secret-y".into(),
790            issuer: "https://example.test".into(),
791            access_token_ttl_secs: None,
792            refresh_token_ttl_secs: None,
793        };
794        OAuthConfig::write_secure(&path, &original).unwrap();
795
796        let bytes = std::fs::read(&path).unwrap();
797        let parsed: OAuthConfig = toml::from_str(std::str::from_utf8(&bytes).unwrap()).unwrap();
798        assert_eq!(parsed.client_id, "id-x");
799        assert_eq!(parsed.client_secret, "secret-y");
800        assert_eq!(parsed.issuer, "https://example.test");
801
802        #[cfg(unix)]
803        {
804            use std::os::unix::fs::PermissionsExt;
805            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
806            assert_eq!(mode, 0o600, "file should be readable only by owner");
807        }
808    }
809
810    fn tempdir() -> PathBuf {
811        let p = std::env::temp_dir().join(format!("things-mcp-test-{}", rand::random::<u64>()));
812        std::fs::create_dir_all(&p).unwrap();
813        p
814    }
815
816    fn test_state() -> OAuthState {
817        let dir = tempdir();
818        OAuthState::with_tokens_path(
819            OAuthConfig {
820                client_id: "test-id".into(),
821                client_secret: "test-secret".into(),
822                issuer: "https://example.test".into(),
823                access_token_ttl_secs: None,
824                refresh_token_ttl_secs: None,
825            },
826            dir.join("tokens.json"),
827        )
828        .unwrap()
829    }
830
831    async fn body_string(resp: axum::response::Response) -> String {
832        let bytes = to_bytes(resp.into_body(), 64 * 1024).await.unwrap();
833        String::from_utf8(bytes.to_vec()).unwrap()
834    }
835
836    #[tokio::test]
837    async fn token_endpoint_issues_for_valid_credentials_via_body() {
838        let app = router(test_state());
839        let resp = app
840            .oneshot(
841                Request::builder()
842                    .method("POST")
843                    .uri("/oauth/token")
844                    .header("content-type", "application/x-www-form-urlencoded")
845                    .body(Body::from(
846                        "grant_type=client_credentials&client_id=test-id&client_secret=test-secret",
847                    ))
848                    .unwrap(),
849            )
850            .await
851            .unwrap();
852        assert_eq!(resp.status(), StatusCode::OK);
853        let body = body_string(resp).await;
854        assert!(body.contains("\"access_token\""), "body was: {body}");
855        assert!(body.contains("\"token_type\":\"Bearer\""));
856        assert!(body.contains("\"expires_in\":604800"));
857    }
858
859    #[tokio::test]
860    async fn token_endpoint_accepts_basic_auth() {
861        let app = router(test_state());
862        let basic = base64::engine::general_purpose::STANDARD.encode("test-id:test-secret");
863        let resp = app
864            .oneshot(
865                Request::builder()
866                    .method("POST")
867                    .uri("/oauth/token")
868                    .header("content-type", "application/x-www-form-urlencoded")
869                    .header("authorization", format!("Basic {basic}"))
870                    .body(Body::from("grant_type=client_credentials"))
871                    .unwrap(),
872            )
873            .await
874            .unwrap();
875        assert_eq!(resp.status(), StatusCode::OK);
876    }
877
878    #[tokio::test]
879    async fn token_endpoint_rejects_bad_secret() {
880        let app = router(test_state());
881        let resp = app
882            .oneshot(
883                Request::builder()
884                    .method("POST")
885                    .uri("/oauth/token")
886                    .header("content-type", "application/x-www-form-urlencoded")
887                    .body(Body::from(
888                        "grant_type=client_credentials&client_id=test-id&client_secret=WRONG",
889                    ))
890                    .unwrap(),
891            )
892            .await
893            .unwrap();
894        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
895        let body = body_string(resp).await;
896        assert!(body.contains("\"error\":\"invalid_client\""));
897    }
898
899    #[tokio::test]
900    async fn token_endpoint_rejects_unsupported_grant() {
901        let app = router(test_state());
902        let resp = app
903            .oneshot(
904                Request::builder()
905                    .method("POST")
906                    .uri("/oauth/token")
907                    .header("content-type", "application/x-www-form-urlencoded")
908                    .body(Body::from(
909                        "grant_type=password&client_id=test-id&client_secret=test-secret",
910                    ))
911                    .unwrap(),
912            )
913            .await
914            .unwrap();
915        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
916        let body = body_string(resp).await;
917        assert!(body.contains("unsupported_grant_type"));
918    }
919
920    #[tokio::test]
921    async fn minted_token_validates_then_expires() {
922        let state = test_state();
923        let pair = state.inner.tokens.mint_pair(None).await.unwrap();
924        assert!(state.validate_token(&pair.access_token).await);
925        assert!(!state.validate_token("not-issued").await);
926    }
927
928    #[tokio::test]
929    async fn discovery_documents_advertise_correct_endpoints() {
930        let app = router(test_state());
931        let resp = app
932            .clone()
933            .oneshot(
934                Request::builder()
935                    .uri("/.well-known/oauth-authorization-server")
936                    .body(Body::empty())
937                    .unwrap(),
938            )
939            .await
940            .unwrap();
941        assert_eq!(resp.status(), StatusCode::OK);
942        let body = body_string(resp).await;
943        assert!(body.contains("\"issuer\":\"https://example.test\""));
944        assert!(body.contains("\"authorization_endpoint\":\"https://example.test/authorize\""));
945        assert!(body.contains("\"token_endpoint\":\"https://example.test/oauth/token\""));
946        assert!(body.contains("\"authorization_code\""));
947        assert!(body.contains("\"client_credentials\""));
948        assert!(
949            body.contains("\"refresh_token\""),
950            "discovery must advertise refresh_token grant; body was: {body}"
951        );
952        assert!(body.contains("\"code_challenge_methods_supported\":[\"S256\"]"));
953
954        let resp = app
955            .oneshot(
956                Request::builder()
957                    .uri("/.well-known/oauth-protected-resource")
958                    .body(Body::empty())
959                    .unwrap(),
960            )
961            .await
962            .unwrap();
963        assert_eq!(resp.status(), StatusCode::OK);
964        let body = body_string(resp).await;
965        assert!(body.contains("\"resource\":\"https://example.test\""));
966        assert!(body.contains("\"authorization_servers\":[\"https://example.test\"]"));
967    }
968
969    #[test]
970    fn pkce_s256_matches_rfc7636_example() {
971        // RFC 7636 Appendix B test vector
972        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
973        let expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
974        assert_eq!(pkce_s256(verifier), expected);
975    }
976
977    fn challenge_for(verifier: &str) -> String {
978        pkce_s256(verifier)
979    }
980
981    #[tokio::test]
982    async fn authorize_endpoint_redirects_with_code() {
983        let app = router(test_state());
984        let verifier = "test-verifier-string-of-reasonable-length-1234";
985        let challenge = challenge_for(verifier);
986        let uri = format!(
987            "/authorize?response_type=code&client_id=test-id&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_challenge={challenge}&code_challenge_method=S256&state=xyz&scope=mcp",
988        );
989        let resp = app
990            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
991            .await
992            .unwrap();
993        assert_eq!(resp.status(), StatusCode::FOUND);
994        let location = resp
995            .headers()
996            .get(header::LOCATION)
997            .unwrap()
998            .to_str()
999            .unwrap();
1000        assert!(location.starts_with("https://claude.ai/api/mcp/auth_callback?code="));
1001        assert!(location.contains("&state=xyz"));
1002    }
1003
1004    #[tokio::test]
1005    async fn authorize_rejects_disallowed_redirect_uri() {
1006        let app = router(test_state());
1007        let uri = "/authorize?response_type=code&client_id=test-id&redirect_uri=https%3A%2F%2Fattacker.example%2Fcb&code_challenge=abc&code_challenge_method=S256&state=z";
1008        let resp = app
1009            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1010            .await
1011            .unwrap();
1012        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1013    }
1014
1015    #[tokio::test]
1016    async fn authorize_rejects_unknown_client_id() {
1017        let app = router(test_state());
1018        let uri = "/authorize?response_type=code&client_id=WRONG&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_challenge=abc&code_challenge_method=S256&state=z";
1019        let resp = app
1020            .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
1021            .await
1022            .unwrap();
1023        // Error is reported via the redirect callback per OAuth spec
1024        assert_eq!(resp.status(), StatusCode::FOUND);
1025        let location = resp
1026            .headers()
1027            .get(header::LOCATION)
1028            .unwrap()
1029            .to_str()
1030            .unwrap();
1031        assert!(location.contains("error=unauthorized_client"));
1032    }
1033
1034    /// Round-trip the full auth-code + PKCE flow through the public router.
1035    #[tokio::test]
1036    async fn auth_code_grant_full_flow_succeeds() {
1037        let state = test_state();
1038        let verifier = "the-verifier-anthropic-would-have-generated";
1039        let challenge = challenge_for(verifier);
1040        let redirect_uri = "https://claude.ai/api/mcp/auth_callback";
1041
1042        // Step 1: /authorize, extract code from Location header.
1043        let auth_uri = format!(
1044            "/authorize?response_type=code&client_id=test-id&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_challenge={challenge}&code_challenge_method=S256&state=opaque-state",
1045        );
1046        let resp = router(state.clone())
1047            .oneshot(
1048                Request::builder()
1049                    .uri(auth_uri)
1050                    .body(Body::empty())
1051                    .unwrap(),
1052            )
1053            .await
1054            .unwrap();
1055        assert_eq!(resp.status(), StatusCode::FOUND);
1056        let location = resp
1057            .headers()
1058            .get(header::LOCATION)
1059            .unwrap()
1060            .to_str()
1061            .unwrap()
1062            .to_string();
1063        let code = location
1064            .split_once("code=")
1065            .and_then(|(_, rest)| rest.split('&').next())
1066            .unwrap()
1067            .to_string();
1068
1069        // Step 2: /oauth/token with grant_type=authorization_code.
1070        let body = format!(
1071            "grant_type=authorization_code&code={code}&redirect_uri={}&code_verifier={verifier}&client_id=test-id",
1072            urlencoding_minimal(redirect_uri)
1073        );
1074        let resp = router(state)
1075            .oneshot(
1076                Request::builder()
1077                    .method("POST")
1078                    .uri("/oauth/token")
1079                    .header("content-type", "application/x-www-form-urlencoded")
1080                    .body(Body::from(body))
1081                    .unwrap(),
1082            )
1083            .await
1084            .unwrap();
1085        assert_eq!(resp.status(), StatusCode::OK);
1086        let body = body_string(resp).await;
1087        assert!(body.contains("\"access_token\""));
1088        assert!(body.contains("\"token_type\":\"Bearer\""));
1089    }
1090
1091    #[tokio::test]
1092    async fn auth_code_rejects_bad_verifier() {
1093        let state = test_state();
1094        let verifier = "correct-verifier";
1095        let challenge = challenge_for(verifier);
1096        let auth_uri = format!(
1097            "/authorize?response_type=code&client_id=test-id&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_challenge={challenge}&code_challenge_method=S256&state=s",
1098        );
1099        let resp = router(state.clone())
1100            .oneshot(
1101                Request::builder()
1102                    .uri(auth_uri)
1103                    .body(Body::empty())
1104                    .unwrap(),
1105            )
1106            .await
1107            .unwrap();
1108        let location = resp
1109            .headers()
1110            .get(header::LOCATION)
1111            .unwrap()
1112            .to_str()
1113            .unwrap()
1114            .to_string();
1115        let code = location
1116            .split_once("code=")
1117            .and_then(|(_, rest)| rest.split('&').next())
1118            .unwrap()
1119            .to_string();
1120
1121        let body = format!(
1122            "grant_type=authorization_code&code={code}&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_verifier=WRONG&client_id=test-id"
1123        );
1124        let resp = router(state)
1125            .oneshot(
1126                Request::builder()
1127                    .method("POST")
1128                    .uri("/oauth/token")
1129                    .header("content-type", "application/x-www-form-urlencoded")
1130                    .body(Body::from(body))
1131                    .unwrap(),
1132            )
1133            .await
1134            .unwrap();
1135        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1136        let body = body_string(resp).await;
1137        assert!(body.contains("invalid_grant"));
1138    }
1139
1140    #[tokio::test]
1141    async fn auth_code_response_includes_refresh_token() {
1142        let state = test_state();
1143        let verifier = "the-verifier-of-reasonable-length";
1144        let challenge = challenge_for(verifier);
1145        let auth_uri = format!(
1146            "/authorize?response_type=code&client_id=test-id&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_challenge={challenge}&code_challenge_method=S256&state=s",
1147        );
1148        let resp = router(state.clone())
1149            .oneshot(
1150                Request::builder()
1151                    .uri(auth_uri)
1152                    .body(Body::empty())
1153                    .unwrap(),
1154            )
1155            .await
1156            .unwrap();
1157        let location = resp
1158            .headers()
1159            .get(header::LOCATION)
1160            .unwrap()
1161            .to_str()
1162            .unwrap()
1163            .to_string();
1164        let code = location
1165            .split_once("code=")
1166            .and_then(|(_, r)| r.split('&').next())
1167            .unwrap()
1168            .to_string();
1169
1170        let body = format!(
1171            "grant_type=authorization_code&code={code}&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_verifier={verifier}&client_id=test-id"
1172        );
1173        let resp = router(state)
1174            .oneshot(
1175                Request::builder()
1176                    .method("POST")
1177                    .uri("/oauth/token")
1178                    .header("content-type", "application/x-www-form-urlencoded")
1179                    .body(Body::from(body))
1180                    .unwrap(),
1181            )
1182            .await
1183            .unwrap();
1184        assert_eq!(resp.status(), StatusCode::OK);
1185        let body = body_string(resp).await;
1186        assert!(body.contains("\"access_token\""), "body was: {body}");
1187        assert!(body.contains("\"refresh_token\""), "body was: {body}");
1188        assert!(
1189            body.contains("\"refresh_expires_in\":7776000"),
1190            "body was: {body}"
1191        );
1192    }
1193
1194    async fn auth_code_full_flow(state: OAuthState) -> (String, String) {
1195        let verifier = "verifier-string-of-decent-length";
1196        let challenge = challenge_for(verifier);
1197        let auth_uri = format!(
1198            "/authorize?response_type=code&client_id=test-id&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_challenge={challenge}&code_challenge_method=S256&state=s",
1199        );
1200        let resp = router(state.clone())
1201            .oneshot(
1202                Request::builder()
1203                    .uri(auth_uri)
1204                    .body(Body::empty())
1205                    .unwrap(),
1206            )
1207            .await
1208            .unwrap();
1209        let location = resp
1210            .headers()
1211            .get(header::LOCATION)
1212            .unwrap()
1213            .to_str()
1214            .unwrap()
1215            .to_string();
1216        let code = location
1217            .split_once("code=")
1218            .and_then(|(_, r)| r.split('&').next())
1219            .unwrap()
1220            .to_string();
1221        let body = format!(
1222            "grant_type=authorization_code&code={code}&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_verifier={verifier}&client_id=test-id"
1223        );
1224        let resp = router(state)
1225            .oneshot(
1226                Request::builder()
1227                    .method("POST")
1228                    .uri("/oauth/token")
1229                    .header("content-type", "application/x-www-form-urlencoded")
1230                    .body(Body::from(body))
1231                    .unwrap(),
1232            )
1233            .await
1234            .unwrap();
1235        let body_str = body_string(resp).await;
1236        let parsed: serde_json::Value = serde_json::from_str(&body_str).unwrap();
1237        let access = parsed["access_token"].as_str().unwrap().to_string();
1238        let refresh = parsed["refresh_token"].as_str().unwrap().to_string();
1239        (access, refresh)
1240    }
1241
1242    async fn post_token(state: OAuthState, body: &str) -> axum::response::Response {
1243        router(state)
1244            .oneshot(
1245                Request::builder()
1246                    .method("POST")
1247                    .uri("/oauth/token")
1248                    .header("content-type", "application/x-www-form-urlencoded")
1249                    .body(Body::from(body.to_string()))
1250                    .unwrap(),
1251            )
1252            .await
1253            .unwrap()
1254    }
1255
1256    #[tokio::test]
1257    async fn refresh_token_grant_returns_new_access_and_refresh() {
1258        let state = test_state();
1259        let (orig_access, refresh) = auth_code_full_flow(state.clone()).await;
1260        let resp = post_token(
1261            state.clone(),
1262            &format!("grant_type=refresh_token&refresh_token={refresh}&client_id=test-id"),
1263        )
1264        .await;
1265        assert_eq!(resp.status(), StatusCode::OK);
1266        let body = body_string(resp).await;
1267        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
1268        let new_access = parsed["access_token"].as_str().unwrap();
1269        let new_refresh = parsed["refresh_token"].as_str().unwrap();
1270        assert_ne!(new_access, orig_access);
1271        assert_ne!(new_refresh, refresh);
1272        assert!(state.validate_token(new_access).await);
1273    }
1274
1275    #[tokio::test]
1276    async fn refresh_token_grant_invalidates_old_refresh_token() {
1277        let state = test_state();
1278        let (_, refresh) = auth_code_full_flow(state.clone()).await;
1279        // First use OK
1280        let r1 = post_token(
1281            state.clone(),
1282            &format!("grant_type=refresh_token&refresh_token={refresh}&client_id=test-id"),
1283        )
1284        .await;
1285        assert_eq!(r1.status(), StatusCode::OK);
1286        // Second use of same refresh token must fail
1287        let r2 = post_token(
1288            state,
1289            &format!("grant_type=refresh_token&refresh_token={refresh}&client_id=test-id"),
1290        )
1291        .await;
1292        assert_eq!(r2.status(), StatusCode::BAD_REQUEST);
1293        let body = body_string(r2).await;
1294        assert!(body.contains("invalid_grant"));
1295    }
1296
1297    #[tokio::test]
1298    async fn refresh_token_replay_revokes_chain() {
1299        let state = test_state();
1300        let (orig_access, refresh) = auth_code_full_flow(state.clone()).await;
1301        let r1 = post_token(
1302            state.clone(),
1303            &format!("grant_type=refresh_token&refresh_token={refresh}&client_id=test-id"),
1304        )
1305        .await;
1306        let body = body_string(r1).await;
1307        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
1308        let new_access = parsed["access_token"].as_str().unwrap().to_string();
1309        // Replay original refresh — must revoke chain.
1310        let _ = post_token(
1311            state.clone(),
1312            &format!("grant_type=refresh_token&refresh_token={refresh}&client_id=test-id"),
1313        )
1314        .await;
1315        // Both the new access AND the original access must now be invalid.
1316        assert!(
1317            !state.validate_token(&new_access).await,
1318            "new access should be revoked after replay"
1319        );
1320        assert!(
1321            !state.validate_token(&orig_access).await,
1322            "original access should be revoked after replay"
1323        );
1324    }
1325
1326    #[tokio::test]
1327    async fn refresh_token_grant_with_unknown_token_returns_invalid_grant() {
1328        let state = test_state();
1329        let resp = post_token(
1330            state,
1331            "grant_type=refresh_token&refresh_token=never-issued&client_id=test-id",
1332        )
1333        .await;
1334        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1335        let body = body_string(resp).await;
1336        assert!(body.contains("invalid_grant"));
1337    }
1338
1339    #[tokio::test]
1340    async fn auth_code_is_single_use() {
1341        let state = test_state();
1342        let verifier = "vvv";
1343        let challenge = challenge_for(verifier);
1344        let auth_uri = format!(
1345            "/authorize?response_type=code&client_id=test-id&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_challenge={challenge}&code_challenge_method=S256&state=s",
1346        );
1347        let resp = router(state.clone())
1348            .oneshot(
1349                Request::builder()
1350                    .uri(auth_uri)
1351                    .body(Body::empty())
1352                    .unwrap(),
1353            )
1354            .await
1355            .unwrap();
1356        let location = resp
1357            .headers()
1358            .get(header::LOCATION)
1359            .unwrap()
1360            .to_str()
1361            .unwrap()
1362            .to_string();
1363        let code = location
1364            .split_once("code=")
1365            .and_then(|(_, r)| r.split('&').next())
1366            .unwrap()
1367            .to_string();
1368        let body = format!(
1369            "grant_type=authorization_code&code={code}&redirect_uri=https%3A%2F%2Fclaude.ai%2Fapi%2Fmcp%2Fauth_callback&code_verifier={verifier}&client_id=test-id"
1370        );
1371        let make_req = || {
1372            Request::builder()
1373                .method("POST")
1374                .uri("/oauth/token")
1375                .header("content-type", "application/x-www-form-urlencoded")
1376                .body(Body::from(body.clone()))
1377                .unwrap()
1378        };
1379        let first = router(state.clone()).oneshot(make_req()).await.unwrap();
1380        assert_eq!(first.status(), StatusCode::OK);
1381        let second = router(state).oneshot(make_req()).await.unwrap();
1382        assert_eq!(second.status(), StatusCode::BAD_REQUEST);
1383    }
1384}