1mod 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
48const 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; pub const DEFAULT_REFRESH_TOKEN_TTL_SECS: u64 = 90 * 24 * 3600; #[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct OAuthConfig {
63 pub client_id: String,
64 pub client_secret: String,
65 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
90pub 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 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 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#[derive(Clone)]
181pub struct OAuthState {
182 inner: Arc<Inner>,
183}
184
185struct Inner {
186 config: OAuthConfig,
187 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 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 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 pub fn resource_metadata_url(&self) -> String {
235 format!(
236 "{}/.well-known/oauth-protected-resource",
237 self.inner.config.issuer
238 )
239 }
240
241 pub async fn validate_token(&self, token: &str) -> bool {
243 self.inner.tokens.validate_access(token).await
244 }
245
246 #[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_id: Option<String>,
314 client_secret: Option<String>,
315 #[serde(default)]
317 code: Option<String>,
318 #[serde(default)]
319 code_verifier: Option<String>,
320 #[serde(default)]
321 redirect_uri: Option<String>,
322 #[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 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 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 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
571fn 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 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
649fn 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
664fn 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
706fn 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
719pub 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 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 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 #[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 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 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 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 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 let _ = post_token(
1311 state.clone(),
1312 &format!("grant_type=refresh_token&refresh_token={refresh}&client_id=test-id"),
1313 )
1314 .await;
1315 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}