1pub mod store;
50
51pub use store::json_file::{JsonFileClientStore, JsonFilePasskeyStore, JsonFileTokenStore};
52pub use store::{
53 AccessTokenEntry, AuthCode, ClientStore, PasskeyStore, RefreshTokenEntry, RegisteredClient,
54 StoreError, TokenStore,
55};
56
57use std::collections::HashMap;
58use std::num::NonZeroU32;
59use std::path::{Component, PathBuf};
60use std::sync::Arc;
61use std::time::{SystemTime, UNIX_EPOCH};
62
63use governor::clock::DefaultClock;
64use governor::state::keyed::DashMapStateStore;
65use governor::{Quota, RateLimiter};
66
67use axum::extract::State;
68use axum::http::{StatusCode, header};
69use axum::middleware::{self, Next};
70use axum::response::{Html, IntoResponse, Response};
71use axum::routing::{get, post};
72use axum::{Form, Json, Router};
73use base64::Engine;
74use base64::engine::general_purpose::URL_SAFE_NO_PAD;
75use rand::TryRngCore;
76use serde::{Deserialize, Serialize};
77use sha2::{Digest, Sha256};
78use subtle::ConstantTimeEq;
79use tokio::sync::Mutex;
80use url::Url;
81use uuid::Uuid;
82use webauthn_rs::prelude::*;
83
84fn now_epoch() -> u64 {
86 SystemTime::now()
87 .duration_since(UNIX_EPOCH)
88 .unwrap_or_default()
89 .as_secs()
90}
91
92fn constant_time_eq(a: &str, b: &str) -> bool {
94 if a.len() != b.len() {
95 return false;
96 }
97 a.as_bytes().ct_eq(b.as_bytes()).into()
98}
99
100use askama::Template;
101
102#[expect(
104 clippy::expect_used,
105 reason = "OsRng::try_fill_bytes only fails on catastrophic OS RNG failure; panicking is the correct response for a security-critical token generator"
106)]
107fn generate_token() -> String {
108 let mut bytes = [0u8; 32];
109 rand::rngs::OsRng
110 .try_fill_bytes(&mut bytes)
111 .expect("OS RNG failed");
112 URL_SAFE_NO_PAD.encode(bytes)
113}
114
115#[derive(Debug, Clone)]
121pub struct RateLimitConfig {
122 pub strict: u32,
125 pub moderate: u32,
128 pub lenient: u32,
131}
132
133impl Default for RateLimitConfig {
134 fn default() -> Self {
135 Self {
136 strict: 10,
137 moderate: 30,
138 lenient: 60,
139 }
140 }
141}
142
143#[derive(Debug, Clone)]
145pub struct CapacityConfig {
146 pub max_registration_states: usize,
148 pub max_authentication_states: usize,
150 pub max_access_tokens: usize,
152 pub max_refresh_tokens: usize,
154 pub max_auth_codes: usize,
156 pub max_registered_clients: Option<usize>,
162}
163
164impl Default for CapacityConfig {
165 fn default() -> Self {
166 Self {
167 max_registration_states: 10_000,
168 max_authentication_states: 10_000,
169 max_access_tokens: 10_000,
170 max_refresh_tokens: 10_000,
171 max_auth_codes: 10_000,
172 max_registered_clients: Some(1),
173 }
174 }
175}
176
177#[derive(Debug, thiserror::Error)]
179#[non_exhaustive]
180pub enum OAuthConfigError {
181 #[error("client_id must not be empty")]
183 EmptyClientId,
184 #[error("client_secret must not be empty")]
186 EmptyClientSecret,
187 #[error("passkey_store_path must not contain '..' components")]
189 PathTraversal,
190 #[error("rate limit values must be non-zero")]
192 ZeroRateLimit,
193 #[error("scopes must not be empty")]
195 EmptyScopes,
196 #[error("capacity limit must be at least 1 (use max_registered_clients: None for unlimited)")]
199 ZeroCapacity,
200}
201
202#[must_use]
204pub fn default_redirect_uris() -> Vec<String> {
205 vec![
206 "https://claude.ai/api/mcp/auth_callback".to_owned(),
207 "https://claude.com/api/mcp/auth_callback".to_owned(),
208 ]
209}
210
211#[non_exhaustive]
212pub struct OAuthConfig {
213 pub server_url: String,
215 pub client_id: String,
217 pub client_secret: String,
219 pub app_name: String,
221 pub passkey_store_path: PathBuf,
223 pub setup_token: Option<String>,
225 pub token_lifetime_secs: u64,
227 pub code_lifetime_secs: u64,
229 pub allowed_redirect_uris: Vec<String>,
232 pub rate_limits: RateLimitConfig,
234 pub capacity: CapacityConfig,
236 pub scopes: Vec<String>,
239}
240
241impl OAuthConfig {
242 #[must_use]
248 pub fn with_defaults(
249 server_url: String,
250 client_id: String,
251 client_secret: String,
252 app_name: String,
253 passkey_store_path: PathBuf,
254 setup_token: Option<String>,
255 ) -> Self {
256 assert!(!client_id.is_empty(), "client_id must not be empty");
258 assert!(!client_secret.is_empty(), "client_secret must not be empty");
259 assert!(
261 !passkey_store_path
262 .components()
263 .any(|c| c == Component::ParentDir),
264 "passkey_store_path must not contain '..' components"
265 );
266
267 Self {
268 server_url,
269 client_id,
270 client_secret,
271 app_name,
272 passkey_store_path,
273 setup_token,
274 token_lifetime_secs: 86400,
275 code_lifetime_secs: 300,
276 allowed_redirect_uris: default_redirect_uris(),
277 rate_limits: RateLimitConfig::default(),
278 capacity: CapacityConfig::default(),
279 scopes: vec!["mcp:tools".to_owned()],
280 }
281 }
282
283 #[must_use]
305 pub fn builder(
306 server_url: String,
307 client_id: String,
308 client_secret: String,
309 app_name: String,
310 passkey_store_path: PathBuf,
311 ) -> OAuthConfigBuilder {
312 OAuthConfigBuilder {
313 server_url,
314 client_id,
315 client_secret,
316 app_name,
317 passkey_store_path,
318 setup_token: None,
319 token_lifetime_secs: 86400,
320 code_lifetime_secs: 300,
321 allowed_redirect_uris: default_redirect_uris(),
322 rate_limits: RateLimitConfig::default(),
323 capacity: CapacityConfig::default(),
324 scopes: vec!["mcp:tools".to_owned()],
325 }
326 }
327}
328
329pub struct OAuthConfigBuilder {
334 server_url: String,
335 client_id: String,
336 client_secret: String,
337 app_name: String,
338 passkey_store_path: PathBuf,
339 setup_token: Option<String>,
340 token_lifetime_secs: u64,
341 code_lifetime_secs: u64,
342 allowed_redirect_uris: Vec<String>,
343 rate_limits: RateLimitConfig,
344 capacity: CapacityConfig,
345 scopes: Vec<String>,
346}
347
348impl OAuthConfigBuilder {
349 #[must_use]
351 pub fn setup_token(mut self, token: impl Into<String>) -> Self {
352 self.setup_token = Some(token.into());
353 self
354 }
355
356 #[must_use]
358 pub const fn token_lifetime_secs(mut self, secs: u64) -> Self {
359 self.token_lifetime_secs = secs;
360 self
361 }
362
363 #[must_use]
365 pub const fn code_lifetime_secs(mut self, secs: u64) -> Self {
366 self.code_lifetime_secs = secs;
367 self
368 }
369
370 #[must_use]
372 pub fn allowed_redirect_uris(mut self, uris: Vec<String>) -> Self {
373 self.allowed_redirect_uris = uris;
374 self
375 }
376
377 #[must_use]
379 pub fn add_redirect_uri(mut self, uri: impl Into<String>) -> Self {
380 self.allowed_redirect_uris.push(uri.into());
381 self
382 }
383
384 #[must_use]
386 pub const fn rate_limits(mut self, config: RateLimitConfig) -> Self {
387 self.rate_limits = config;
388 self
389 }
390
391 #[must_use]
393 pub const fn capacity(mut self, config: CapacityConfig) -> Self {
394 self.capacity = config;
395 self
396 }
397
398 #[must_use]
400 pub const fn max_access_tokens(mut self, n: usize) -> Self {
401 self.capacity.max_access_tokens = n;
402 self
403 }
404
405 #[must_use]
407 pub const fn max_refresh_tokens(mut self, n: usize) -> Self {
408 self.capacity.max_refresh_tokens = n;
409 self
410 }
411
412 #[must_use]
414 pub const fn max_auth_codes(mut self, n: usize) -> Self {
415 self.capacity.max_auth_codes = n;
416 self
417 }
418
419 #[must_use]
425 pub const fn max_registered_clients(mut self, n: Option<usize>) -> Self {
426 self.capacity.max_registered_clients = n;
427 self
428 }
429
430 #[must_use]
432 pub fn scopes(mut self, scopes: Vec<String>) -> Self {
433 self.scopes = scopes;
434 self
435 }
436
437 #[must_use]
439 pub fn add_scope(mut self, scope: impl Into<String>) -> Self {
440 self.scopes.push(scope.into());
441 self
442 }
443
444 pub fn build(self) -> Result<OAuthConfig, OAuthConfigError> {
450 if self.client_id.is_empty() {
451 return Err(OAuthConfigError::EmptyClientId);
452 }
453 if self.client_secret.is_empty() {
454 return Err(OAuthConfigError::EmptyClientSecret);
455 }
456 if self
457 .passkey_store_path
458 .components()
459 .any(|c| c == Component::ParentDir)
460 {
461 return Err(OAuthConfigError::PathTraversal);
462 }
463 if self.rate_limits.strict == 0
464 || self.rate_limits.moderate == 0
465 || self.rate_limits.lenient == 0
466 {
467 return Err(OAuthConfigError::ZeroRateLimit);
468 }
469 if self.scopes.is_empty() {
470 return Err(OAuthConfigError::EmptyScopes);
471 }
472 if self.capacity.max_access_tokens == 0
473 || self.capacity.max_refresh_tokens == 0
474 || self.capacity.max_auth_codes == 0
475 || self.capacity.max_registration_states == 0
476 || self.capacity.max_authentication_states == 0
477 || self.capacity.max_registered_clients == Some(0)
478 {
479 return Err(OAuthConfigError::ZeroCapacity);
480 }
481
482 Ok(OAuthConfig {
483 server_url: self.server_url,
484 client_id: self.client_id,
485 client_secret: self.client_secret,
486 app_name: self.app_name,
487 passkey_store_path: self.passkey_store_path,
488 setup_token: self.setup_token,
489 token_lifetime_secs: self.token_lifetime_secs,
490 code_lifetime_secs: self.code_lifetime_secs,
491 allowed_redirect_uris: self.allowed_redirect_uris,
492 rate_limits: self.rate_limits,
493 capacity: self.capacity,
494 scopes: self.scopes,
495 })
496 }
497}
498
499#[derive(Clone)]
504struct PendingAuthApproval {
505 client_id: String,
506 redirect_uri: String,
507 state: Option<String>,
508 code_challenge: String,
509 #[expect(
510 dead_code,
511 reason = "retained for Debug logging; the OAuth spec only defines S256 for us, but the field is kept so the pending-approval record round-trips exactly what the client sent"
512 )]
513 code_challenge_method: String,
514}
515
516use store::TRANSIENT_STATE_TTL_SECS;
518
519struct OAuthServer<T: TokenStore, C: ClientStore, P: PasskeyStore> {
520 config: OAuthConfig,
521 token_store: T,
522 client_store: C,
523 passkey_store: P,
524 webauthn: Webauthn,
526 registration_states: Mutex<HashMap<String, (PasskeyRegistration, u64)>>,
528 authentication_states:
529 Mutex<HashMap<String, (PasskeyAuthentication, PendingAuthApproval, u64)>>,
530 auth_session_token: Mutex<Option<(String, u64)>>, }
533
534type AppState<T, C, P> = Arc<OAuthServer<T, C, P>>;
537
538fn extract_domain(server_url: &str) -> Result<String, String> {
540 Url::parse(server_url)
541 .ok()
542 .and_then(|u| u.host_str().map(ToString::to_string))
543 .ok_or_else(|| format!("cannot extract domain from URL: {server_url}"))
544}
545
546impl<T: TokenStore, C: ClientStore, P: PasskeyStore> OAuthServer<T, C, P> {
547 async fn validate_client(&self, client_id: &str, client_secret: &str) -> bool {
549 let id_match = constant_time_eq(client_id, &self.config.client_id);
550 let secret_match = constant_time_eq(client_secret, &self.config.client_secret);
551 if id_match && secret_match {
552 return true;
553 }
554 match self.client_store.get_client(client_id).await {
555 Ok(Some(c)) => constant_time_eq(client_secret, &c.client_secret),
556 _ => false,
557 }
558 }
559
560 async fn is_known_client(&self, client_id: &str) -> bool {
561 if client_id == self.config.client_id {
562 return true;
563 }
564 matches!(self.client_store.get_client(client_id).await, Ok(Some(_)))
565 }
566
567 async fn is_redirect_uri_allowed(&self, client_id: &str, redirect_uri: &str) -> bool {
568 if self
569 .config
570 .allowed_redirect_uris
571 .iter()
572 .any(|u| u == redirect_uri)
573 {
574 return true;
575 }
576 match self.client_store.get_client(client_id).await {
577 Ok(Some(c)) => c.redirect_uris.iter().any(|u| u == redirect_uri),
578 _ => false,
579 }
580 }
581
582 async fn has_passkeys(&self) -> bool {
583 match self.passkey_store.has_passkeys().await {
584 Ok(v) => v,
585 Err(e) => {
586 tracing::error!("Passkey store error in has_passkeys: {e}");
587 false
588 }
589 }
590 }
591
592 async fn create_auth_session(&self) -> String {
593 let token = generate_token();
594 *self.auth_session_token.lock().await = Some((token.clone(), now_epoch()));
595 token
596 }
597
598 async fn validate_auth_session(&self, cookie_token: &str) -> bool {
599 let session = self.auth_session_token.lock().await;
600 match session.as_ref() {
601 Some((token, created_at)) => {
602 let age = now_epoch().saturating_sub(*created_at);
603 age < self.config.token_lifetime_secs && constant_time_eq(cookie_token, token)
604 }
605 None => false,
606 }
607 }
608}
609
610type IpRateLimiter = RateLimiter<String, DashMapStateStore<String>, DefaultClock>;
615
616#[expect(
617 clippy::unwrap_used,
618 reason = "requests_per_minute is validated as non-zero by OAuthConfigBuilder::build (ZeroRateLimit error), so NonZeroU32::new cannot return None here"
619)]
620fn create_rate_limiter(requests_per_minute: u32) -> Arc<IpRateLimiter> {
621 let quota = Quota::per_minute(NonZeroU32::new(requests_per_minute).unwrap());
622 Arc::new(RateLimiter::dashmap(quota))
623}
624
625fn extract_client_ip(req: &axum::extract::Request) -> String {
629 if let Some(ip) = req
630 .headers()
631 .get("CF-Connecting-IP")
632 .and_then(|v| v.to_str().ok())
633 {
634 return ip.to_string();
635 }
636 if let Some(xff) = req
637 .headers()
638 .get("X-Forwarded-For")
639 .and_then(|v| v.to_str().ok())
640 && let Some(first) = xff.split(',').next()
641 {
642 return first.trim().to_string();
643 }
644 if let Some(connect_info) = req
647 .extensions()
648 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
649 {
650 return connect_info.0.ip().to_string();
651 }
652 tracing::warn!("Could not determine client IP; using shared \"unknown\" rate-limit bucket");
653 "unknown".to_string()
654}
655
656async fn rate_limit_middleware(
657 State(limiter): State<Arc<IpRateLimiter>>,
658 req: axum::extract::Request,
659 next: Next,
660) -> Result<Response, Response> {
661 let ip = extract_client_ip(&req);
662 if limiter.check_key(&ip).is_ok() {
663 Ok(next.run(req).await)
664 } else {
665 tracing::warn!("Rate limit exceeded for IP: {ip}");
666 Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded\n").into_response())
667 }
668}
669
670pub fn create_default_stores(
679 config: &OAuthConfig,
680) -> (impl TokenStore, impl ClientStore, impl PasskeyStore) {
681 let caps = store::json_file::StoreCaps {
682 max_access_tokens: config.capacity.max_access_tokens,
683 max_refresh_tokens: config.capacity.max_refresh_tokens,
684 max_auth_codes: config.capacity.max_auth_codes,
685 max_registered_clients: config.capacity.max_registered_clients,
686 };
687 let (token_store, client_store, summary) =
688 store::json_file::create_json_file_stores(&config.passkey_store_path, caps);
689
690 tracing::info!(
691 "OAuth store loaded: {} access_tokens, {} refresh_tokens, {} registered_clients from {:?}",
692 summary.access_tokens,
693 summary.refresh_tokens,
694 summary.registered_clients,
695 summary.tokens_path,
696 );
697
698 let passkey_store = JsonFilePasskeyStore::new(config.passkey_store_path.clone());
699
700 (token_store, client_store, passkey_store)
701}
702
703#[deprecated(
714 since = "0.2.0",
715 note = "use `build_oauth_router_with_stores` with explicit store implementations"
716)]
717pub fn build_oauth_router(protected_router: Router, config: OAuthConfig) -> Router {
718 let (token_store, client_store, passkey_store) = create_default_stores(&config);
719 build_oauth_router_with_stores(
720 protected_router,
721 config,
722 token_store,
723 client_store,
724 passkey_store,
725 )
726}
727
728#[expect(
742 clippy::expect_used,
743 reason = "invalid server_url / WebAuthn RP config is a caller bug at startup, not a runtime condition; panicking surfaces it immediately rather than threading a Result through the public API"
744)]
745pub fn build_oauth_router_with_stores<T, C, P>(
746 protected_router: Router,
747 config: OAuthConfig,
748 token_store: T,
749 client_store: C,
750 passkey_store: P,
751) -> Router
752where
753 T: TokenStore,
754 C: ClientStore,
755 P: PasskeyStore,
756{
757 let rp_id =
758 extract_domain(&config.server_url).expect("invalid server_url: cannot extract domain");
759 let rp_origin = Url::parse(&config.server_url).expect("invalid server_url");
760 let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
761 .expect("Failed to build WebAuthn")
762 .rp_name(&config.app_name)
763 .build()
764 .expect("Failed to build WebAuthn");
765
766 tracing::info!(
767 "Token/passkey files are stored at {:?}. Ensure this directory is owned by the service user with 0o700 permissions.",
768 config
769 .passkey_store_path
770 .parent()
771 .unwrap_or_else(|| std::path::Path::new(".")),
772 );
773
774 let store: AppState<T, C, P> = Arc::new(OAuthServer {
775 config,
776 token_store,
777 client_store,
778 passkey_store,
779 webauthn,
780 registration_states: Mutex::new(HashMap::new()),
781 authentication_states: Mutex::new(HashMap::new()),
782 auth_session_token: Mutex::new(None),
783 });
784
785 let strict_limiter = create_rate_limiter(store.config.rate_limits.strict);
786 let moderate_limiter = create_rate_limiter(store.config.rate_limits.moderate);
787 let lenient_limiter = create_rate_limiter(store.config.rate_limits.lenient);
788
789 let auth_routes = Router::new()
791 .route("/register", post(register_client::<T, C, P>))
792 .route("/token", post(token::<T, C, P>))
793 .route("/passkey/register", get(passkey_register_page::<T, C, P>))
794 .route(
795 "/passkey/register/start",
796 post(passkey_register_start::<T, C, P>),
797 )
798 .route(
799 "/passkey/register/finish",
800 post(passkey_register_finish::<T, C, P>),
801 )
802 .route("/passkey/auth/start", post(passkey_auth_start::<T, C, P>))
803 .route("/passkey/auth/finish", post(passkey_auth_finish::<T, C, P>))
804 .with_state(store.clone())
805 .layer(middleware::from_fn_with_state(
806 strict_limiter,
807 rate_limit_middleware,
808 ));
809
810 let other_public = Router::new()
812 .route(
813 "/.well-known/oauth-protected-resource",
814 get(protected_resource_metadata::<T, C, P>),
815 )
816 .route(
817 "/.well-known/oauth-authorization-server",
818 get(authorization_server_metadata::<T, C, P>),
819 )
820 .route("/authorize", get(authorize_get::<T, C, P>))
821 .route("/health", get(|| async { "ok" }))
822 .with_state(store.clone())
823 .layer(middleware::from_fn_with_state(
824 moderate_limiter,
825 rate_limit_middleware,
826 ));
827
828 let public_routes = auth_routes
830 .merge(other_public)
831 .layer(middleware::from_fn(security_headers_middleware));
832
833 let protected = protected_router
835 .layer(middleware::from_fn_with_state(
836 store,
837 auth_middleware::<T, C, P>,
838 ))
839 .layer(middleware::from_fn_with_state(
840 lenient_limiter,
841 rate_limit_middleware,
842 ));
843
844 public_routes
845 .merge(protected)
846 .layer(middleware::from_fn(request_logging_middleware))
847}
848
849async fn request_logging_middleware(req: axum::extract::Request, next: Next) -> Response {
851 let method = req.method().clone();
852 let uri = req.uri().clone();
853 let has_auth = req.headers().contains_key(header::AUTHORIZATION);
854 let session_id = req
855 .headers()
856 .get("mcp-session-id")
857 .and_then(|v| v.to_str().ok())
858 .map(|s| s[..s.len().min(12)].to_owned());
859 tracing::info!(
860 "-> {method} {uri} (auth={has_auth}, session={session})",
861 session = session_id.as_deref().unwrap_or("none")
862 );
863 next.run(req).await
864}
865
866#[expect(
868 clippy::unwrap_used,
869 reason = "HeaderValue::from_static equivalents parsed from ASCII-only string literals cannot fail; any failure would be a compile-time bug in the literal"
870)]
871async fn security_headers_middleware(req: axum::extract::Request, next: Next) -> Response {
872 let mut response = next.run(req).await;
873 let headers = response.headers_mut();
874 headers.insert("X-Frame-Options", "DENY".parse().unwrap());
875 headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap());
876 headers.insert(
877 "Content-Security-Policy",
878 "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; frame-ancestors 'none'"
879 .parse()
880 .unwrap(),
881 );
882 headers.insert("Referrer-Policy", "no-referrer".parse().unwrap());
883 headers.insert(
884 "Permissions-Policy",
885 "camera=(), microphone=(), geolocation=(), payment=()"
886 .parse()
887 .unwrap(),
888 );
889 response
890}
891
892async fn protected_resource_metadata<T: TokenStore, C: ClientStore, P: PasskeyStore>(
897 State(store): State<AppState<T, C, P>>,
898) -> impl IntoResponse {
899 let url = &store.config.server_url;
900 Json(serde_json::json!({
901 "resource": url,
902 "authorization_servers": [url],
903 "bearer_methods_supported": ["header"]
904 }))
905}
906
907async fn authorization_server_metadata<T: TokenStore, C: ClientStore, P: PasskeyStore>(
908 State(store): State<AppState<T, C, P>>,
909) -> impl IntoResponse {
910 let url = &store.config.server_url;
911 let client_count = store.client_store.client_count().await.unwrap_or(0);
912 let mut metadata = serde_json::json!({
913 "issuer": url,
914 "authorization_endpoint": format!("{url}/authorize"),
915 "token_endpoint": format!("{url}/token"),
916 "response_types_supported": ["code"],
917 "grant_types_supported": ["authorization_code", "refresh_token"],
918 "code_challenge_methods_supported": ["S256"],
919 "token_endpoint_auth_methods_supported": ["client_secret_post"],
920 "scopes_supported": store.config.scopes
921 });
922 let registration_open = store
927 .config
928 .capacity
929 .max_registered_clients
930 .is_none_or(|cap| client_count < cap);
931 if registration_open {
932 metadata["registration_endpoint"] = serde_json::json!(format!("{url}/register"));
933 }
934 Json(metadata)
935}
936
937#[derive(Deserialize)]
942struct RegisterClientRequest {
943 client_name: Option<String>,
944 redirect_uris: Vec<String>,
945 #[expect(
946 dead_code,
947 reason = "deserialized per RFC 7591 but intentionally ignored: this server only issues authorization_code + refresh_token grants with client_secret_post auth, advertised via metadata"
948 )]
949 grant_types: Option<Vec<String>>,
950 #[expect(
951 dead_code,
952 reason = "deserialized per RFC 7591 but intentionally ignored: see grant_types above"
953 )]
954 response_types: Option<Vec<String>>,
955 #[expect(
956 dead_code,
957 reason = "deserialized per RFC 7591 but intentionally ignored: see grant_types above"
958 )]
959 token_endpoint_auth_method: Option<String>,
960}
961
962#[derive(Serialize)]
963struct RegisterClientResponse {
964 client_id: String,
965 client_secret: String,
966 client_name: String,
967 redirect_uris: Vec<String>,
968 grant_types: Vec<String>,
969 response_types: Vec<String>,
970 token_endpoint_auth_method: String,
971}
972
973async fn register_client<T: TokenStore, C: ClientStore, P: PasskeyStore>(
974 State(store): State<AppState<T, C, P>>,
975 Json(body): Json<RegisterClientRequest>,
976) -> Result<Json<RegisterClientResponse>, (StatusCode, Json<ErrorResponse>)> {
977 for uri in &body.redirect_uris {
978 if !store.config.allowed_redirect_uris.iter().any(|u| u == uri) {
979 return Err((
980 StatusCode::BAD_REQUEST,
981 Json(ErrorResponse {
982 error: "invalid_redirect_uri".into(),
983 error_description: Some("Redirect URI not allowed".into()),
984 }),
985 ));
986 }
987 }
988
989 let client_id = generate_token();
991 let client_secret = generate_token();
992 let client_name = body
993 .client_name
994 .clone()
995 .unwrap_or_else(|| "MCP Client".into());
996
997 tracing::info!(
998 "POST /register: new client_id={} name={:?} redirect_uris={:?}",
999 &client_id[..8],
1000 client_name,
1001 body.redirect_uris,
1002 );
1003
1004 let registered = store
1007 .client_store
1008 .try_register_client(
1009 client_id.clone(),
1010 RegisteredClient {
1011 client_secret: client_secret.clone(),
1012 redirect_uris: body.redirect_uris.clone(),
1013 },
1014 )
1015 .await
1016 .map_err(|e| store_error_response("Failed to persist client registration", &e))?;
1017
1018 if !registered {
1019 return Err((
1020 StatusCode::FORBIDDEN,
1021 Json(ErrorResponse {
1022 error: "registration_locked".into(),
1023 error_description: Some(
1024 "Client registration is locked: the configured max_registered_clients cap has been reached."
1025 .into(),
1026 ),
1027 }),
1028 ));
1029 }
1030
1031 Ok(Json(RegisterClientResponse {
1032 client_id,
1033 client_secret,
1034 client_name,
1035 redirect_uris: body.redirect_uris,
1036 grant_types: vec!["authorization_code".into(), "refresh_token".into()],
1037 response_types: vec!["code".into()],
1038 token_endpoint_auth_method: "client_secret_post".into(),
1039 }))
1040}
1041
1042#[derive(Deserialize)]
1047struct AuthorizeParams {
1048 response_type: Option<String>,
1049 client_id: Option<String>,
1050 redirect_uri: Option<String>,
1051 state: Option<String>,
1052 code_challenge: Option<String>,
1053 code_challenge_method: Option<String>,
1054 scope: Option<String>,
1055 #[expect(
1056 dead_code,
1057 reason = "RFC 8707 Resource Indicator placeholder; tracked for issue #14 but not yet honoured"
1058 )]
1059 resource: Option<String>,
1060}
1061
1062#[expect(
1063 clippy::similar_names,
1064 clippy::too_many_lines,
1065 reason = "`redirect_uri` (OAuth parameter) and `redirect_url` (parsed Url for redirect building) are distinct and canonically named; the authorize flow is linear and splitting it would obscure the check-then-issue logic"
1066)]
1067async fn authorize_get<T: TokenStore, C: ClientStore, P: PasskeyStore>(
1068 State(store): State<AppState<T, C, P>>,
1069 req: axum::extract::Request,
1070) -> Result<Response, (StatusCode, Html<String>)> {
1071 let query = req.uri().query().unwrap_or("");
1072 let params: AuthorizeParams = match serde_urlencoded::from_str(query) {
1073 Ok(p) => p,
1074 Err(e) => {
1075 tracing::warn!("Malformed /authorize query string: {e}");
1076 AuthorizeParams {
1077 response_type: None,
1078 client_id: None,
1079 redirect_uri: None,
1080 state: None,
1081 code_challenge: None,
1082 code_challenge_method: None,
1083 scope: None,
1084 resource: None,
1085 }
1086 }
1087 };
1088
1089 let response_type = params.response_type.as_deref().unwrap_or("");
1090 let client_id = params.client_id.as_deref().unwrap_or("");
1091 let redirect_uri = params.redirect_uri.as_deref().unwrap_or("");
1092 let code_challenge = params.code_challenge.as_deref().unwrap_or("");
1093 let code_challenge_method = params.code_challenge_method.as_deref().unwrap_or("");
1094
1095 if response_type != "code" {
1096 return Err((
1097 StatusCode::BAD_REQUEST,
1098 Html(error_page(
1099 &store.config.app_name,
1100 "Invalid response_type. Expected 'code'.",
1101 )),
1102 ));
1103 }
1104 if !store.is_known_client(client_id).await {
1105 return Err((
1106 StatusCode::BAD_REQUEST,
1107 Html(error_page(&store.config.app_name, "Unknown client_id.")),
1108 ));
1109 }
1110 if !store.is_redirect_uri_allowed(client_id, redirect_uri).await {
1111 return Err((
1112 StatusCode::BAD_REQUEST,
1113 Html(error_page(
1114 &store.config.app_name,
1115 "Redirect URI not allowed.",
1116 )),
1117 ));
1118 }
1119 if code_challenge_method != "S256" {
1120 return Err((
1121 StatusCode::BAD_REQUEST,
1122 Html(error_page(
1123 &store.config.app_name,
1124 "code_challenge_method must be S256.",
1125 )),
1126 ));
1127 }
1128 if code_challenge.is_empty() {
1129 return Err((
1130 StatusCode::BAD_REQUEST,
1131 Html(error_page(
1132 &store.config.app_name,
1133 "code_challenge is required.",
1134 )),
1135 ));
1136 }
1137
1138 let cookie_header = req
1140 .headers()
1141 .get(header::COOKIE)
1142 .and_then(|v| v.to_str().ok())
1143 .unwrap_or("");
1144 let session_cookie = cookie_header
1145 .split(';')
1146 .find_map(|c| c.trim().strip_prefix("auth_session="));
1147
1148 if let Some(cookie_val) = session_cookie
1149 && store.validate_auth_session(cookie_val).await
1150 {
1151 tracing::info!(
1152 "Auto-approving /authorize via session cookie for client {}...",
1153 &client_id[..client_id.len().min(8)]
1154 );
1155 let code = generate_token();
1156 let now = now_epoch();
1157
1158 if let Err(e) = store
1159 .token_store
1160 .store_auth_code(
1161 code.clone(),
1162 AuthCode {
1163 client_id: client_id.to_owned(),
1164 redirect_uri: redirect_uri.to_owned(),
1165 code_challenge: code_challenge.to_owned(),
1166 created_at: now,
1167 },
1168 )
1169 .await
1170 {
1171 tracing::error!("Failed to store auth code: {e}");
1172 return Err((
1173 StatusCode::TOO_MANY_REQUESTS,
1174 Html(error_page(
1175 &store.config.app_name,
1176 "Too many pending authorization codes.",
1177 )),
1178 ));
1179 }
1180
1181 let mut redirect_url = Url::parse(redirect_uri).map_err(|_| {
1183 (
1184 StatusCode::BAD_REQUEST,
1185 Html(error_page(&store.config.app_name, "Invalid redirect URI.")),
1186 )
1187 })?;
1188 {
1189 let mut pairs = redirect_url.query_pairs_mut();
1190 pairs.append_pair("code", &code);
1191 if let Some(state) = ¶ms.state {
1192 pairs.append_pair("state", state);
1193 }
1194 }
1195 return Ok((
1196 StatusCode::FOUND,
1197 [(header::LOCATION, redirect_url.to_string())],
1198 )
1199 .into_response());
1200 }
1201
1202 let has_passkeys = store.has_passkeys().await;
1203
1204 Ok(Html(authorize_page(
1205 &store.config.app_name,
1206 client_id,
1207 redirect_uri,
1208 params.state.as_deref().unwrap_or(""),
1209 code_challenge,
1210 code_challenge_method,
1211 params.scope.as_deref().unwrap_or(""),
1212 has_passkeys,
1213 ))
1214 .into_response())
1215}
1216
1217#[derive(Deserialize)]
1222struct TokenRequest {
1223 grant_type: String,
1224 code: Option<String>,
1225 redirect_uri: Option<String>,
1226 client_id: Option<String>,
1227 client_secret: Option<String>,
1228 code_verifier: Option<String>,
1229 refresh_token: Option<String>,
1230}
1231
1232#[derive(Serialize)]
1233struct TokenResponse {
1234 access_token: String,
1235 token_type: String,
1236 expires_in: u64,
1237 refresh_token: String,
1238 scope: String,
1239}
1240
1241#[derive(Serialize)]
1242struct ErrorResponse {
1243 error: String,
1244 error_description: Option<String>,
1245}
1246
1247async fn token<T: TokenStore, C: ClientStore, P: PasskeyStore>(
1248 State(store): State<AppState<T, C, P>>,
1249 Form(params): Form<TokenRequest>,
1250) -> Result<Json<TokenResponse>, (StatusCode, Json<ErrorResponse>)> {
1251 let client_id = params.client_id.as_deref().unwrap_or("");
1252 let client_secret = params.client_secret.as_deref().unwrap_or("");
1253
1254 tracing::info!(
1255 "POST /token: grant_type={} client_id={}...",
1256 params.grant_type,
1257 &client_id[..client_id.len().min(8)]
1258 );
1259
1260 if !store.validate_client(client_id, client_secret).await {
1261 let known = store.is_known_client(client_id).await;
1262 tracing::warn!(
1263 "POST /token: invalid client credentials for client_id={}... (client known={})",
1264 &client_id[..client_id.len().min(8)],
1265 known
1266 );
1267 return Err((
1268 StatusCode::UNAUTHORIZED,
1269 Json(ErrorResponse {
1270 error: "invalid_client".into(),
1271 error_description: Some("Invalid client credentials".into()),
1272 }),
1273 ));
1274 }
1275
1276 match params.grant_type.as_str() {
1277 "authorization_code" => handle_authorization_code(&store, client_id, ¶ms).await,
1278 "refresh_token" => handle_refresh_token(&store, client_id, ¶ms).await,
1279 _ => Err((
1280 StatusCode::BAD_REQUEST,
1281 Json(ErrorResponse {
1282 error: "unsupported_grant_type".into(),
1283 error_description: None,
1284 }),
1285 )),
1286 }
1287}
1288
1289async fn handle_authorization_code<T: TokenStore, C: ClientStore, P: PasskeyStore>(
1290 store: &OAuthServer<T, C, P>,
1291 client_id: &str,
1292 params: &TokenRequest,
1293) -> Result<Json<TokenResponse>, (StatusCode, Json<ErrorResponse>)> {
1294 let code = params.code.as_deref().unwrap_or("");
1295 let redirect_uri = params.redirect_uri.as_deref().unwrap_or("");
1296 let code_verifier = params.code_verifier.as_deref().unwrap_or("");
1297
1298 if code_verifier.len() < 43 || code_verifier.len() > 128 {
1300 return Err((
1301 StatusCode::BAD_REQUEST,
1302 Json(ErrorResponse {
1303 error: "invalid_grant".into(),
1304 error_description: Some(
1305 "code_verifier must be 43-128 characters (RFC 7636)".into(),
1306 ),
1307 }),
1308 ));
1309 }
1310
1311 let auth_code = store
1312 .token_store
1313 .consume_auth_code(code)
1314 .await
1315 .map_err(|e| store_error_response("Internal storage error", &e))?;
1316
1317 let Some(auth_code) = auth_code else {
1318 return Err((
1319 StatusCode::BAD_REQUEST,
1320 Json(ErrorResponse {
1321 error: "invalid_grant".into(),
1322 error_description: Some("Invalid or expired authorization code".into()),
1323 }),
1324 ));
1325 };
1326
1327 if now_epoch().saturating_sub(auth_code.created_at) > store.config.code_lifetime_secs {
1328 return Err((
1329 StatusCode::BAD_REQUEST,
1330 Json(ErrorResponse {
1331 error: "invalid_grant".into(),
1332 error_description: Some("Authorization code expired".into()),
1333 }),
1334 ));
1335 }
1336
1337 if auth_code.redirect_uri != redirect_uri {
1338 return Err((
1339 StatusCode::BAD_REQUEST,
1340 Json(ErrorResponse {
1341 error: "invalid_grant".into(),
1342 error_description: Some("redirect_uri mismatch".into()),
1343 }),
1344 ));
1345 }
1346 if auth_code.client_id != client_id {
1347 return Err((
1348 StatusCode::BAD_REQUEST,
1349 Json(ErrorResponse {
1350 error: "invalid_grant".into(),
1351 error_description: Some("client_id mismatch".into()),
1352 }),
1353 ));
1354 }
1355
1356 let computed_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
1357 if !constant_time_eq(&computed_challenge, &auth_code.code_challenge) {
1358 return Err((
1359 StatusCode::BAD_REQUEST,
1360 Json(ErrorResponse {
1361 error: "invalid_grant".into(),
1362 error_description: Some("PKCE verification failed".into()),
1363 }),
1364 ));
1365 }
1366
1367 let access_token = generate_token();
1369 let refresh_token = generate_token();
1370
1371 store
1373 .token_store
1374 .store_access_token(
1375 access_token.clone(),
1376 AccessTokenEntry {
1377 client_id: client_id.to_owned(),
1378 created_at: now_epoch(),
1379 expires_in_secs: store.config.token_lifetime_secs,
1380 refresh_token: refresh_token.clone(),
1381 },
1382 )
1383 .await
1384 .map_err(|e| store_error_response("Too many active tokens", &e))?;
1385
1386 store
1387 .token_store
1388 .store_refresh_token(
1389 refresh_token.clone(),
1390 RefreshTokenEntry {
1391 client_id: client_id.to_owned(),
1392 },
1393 )
1394 .await
1395 .map_err(|e| store_error_response("Too many active refresh tokens", &e))?;
1396
1397 Ok(Json(TokenResponse {
1398 access_token,
1399 token_type: "Bearer".into(),
1400 expires_in: store.config.token_lifetime_secs,
1401 refresh_token,
1402 scope: store.config.scopes.join(" "),
1403 }))
1404}
1405
1406async fn handle_refresh_token<T: TokenStore, C: ClientStore, P: PasskeyStore>(
1407 store: &OAuthServer<T, C, P>,
1408 client_id: &str,
1409 params: &TokenRequest,
1410) -> Result<Json<TokenResponse>, (StatusCode, Json<ErrorResponse>)> {
1411 let refresh_token_val = params.refresh_token.as_deref().unwrap_or("");
1412
1413 let entry = store
1417 .token_store
1418 .get_refresh_token(refresh_token_val)
1419 .await
1420 .map_err(|e| store_error_response("Internal storage error", &e))?;
1421
1422 let Some(entry) = entry else {
1423 tracing::warn!(
1424 "Refresh token not found (already consumed or never existed), client_id={}...",
1425 &client_id[..client_id.len().min(8)]
1426 );
1427 return Err((
1428 StatusCode::BAD_REQUEST,
1429 Json(ErrorResponse {
1430 error: "invalid_grant".into(),
1431 error_description: Some("Invalid refresh token".into()),
1432 }),
1433 ));
1434 };
1435
1436 if entry.client_id != client_id {
1437 tracing::warn!(
1438 "Refresh token client_id mismatch: token belongs to {} but request from {}",
1439 &entry.client_id[..entry.client_id.len().min(8)],
1440 &client_id[..client_id.len().min(8)]
1441 );
1442 return Err((
1443 StatusCode::BAD_REQUEST,
1444 Json(ErrorResponse {
1445 error: "invalid_grant".into(),
1446 error_description: Some("client_id mismatch".into()),
1447 }),
1448 ));
1449 }
1450
1451 store
1453 .token_store
1454 .consume_refresh_token(refresh_token_val)
1455 .await
1456 .map_err(|e| store_error_response("Internal storage error", &e))?;
1457
1458 tracing::info!(
1459 "Refresh token valid, issuing new tokens for client_id={}...",
1460 &client_id[..client_id.len().min(8)]
1461 );
1462
1463 store
1466 .token_store
1467 .revoke_access_tokens_by_refresh(refresh_token_val)
1468 .await
1469 .map_err(|e| store_error_response("Failed to revoke old access tokens", &e))?;
1470
1471 let new_access_token = generate_token();
1473 let new_refresh_token = generate_token();
1474
1475 store
1476 .token_store
1477 .store_access_token(
1478 new_access_token.clone(),
1479 AccessTokenEntry {
1480 client_id: client_id.to_owned(),
1481 created_at: now_epoch(),
1482 expires_in_secs: store.config.token_lifetime_secs,
1483 refresh_token: new_refresh_token.clone(),
1484 },
1485 )
1486 .await
1487 .map_err(|e| store_error_response("Failed to store access token", &e))?;
1488
1489 store
1490 .token_store
1491 .store_refresh_token(
1492 new_refresh_token.clone(),
1493 RefreshTokenEntry {
1494 client_id: client_id.to_owned(),
1495 },
1496 )
1497 .await
1498 .map_err(|e| store_error_response("Failed to store refresh token", &e))?;
1499
1500 Ok(Json(TokenResponse {
1501 access_token: new_access_token,
1502 token_type: "Bearer".into(),
1503 expires_in: store.config.token_lifetime_secs,
1504 refresh_token: new_refresh_token,
1505 scope: store.config.scopes.join(" "),
1506 }))
1507}
1508
1509async fn auth_middleware<T: TokenStore, C: ClientStore, P: PasskeyStore>(
1514 State(store): State<AppState<T, C, P>>,
1515 req: axum::extract::Request,
1516 next: Next,
1517) -> Result<Response, Response> {
1518 let auth_header = req
1519 .headers()
1520 .get(header::AUTHORIZATION)
1521 .and_then(|v| v.to_str().ok());
1522
1523 let Some(h) = auth_header.filter(|h| h.len() > 7 && h[..7].eq_ignore_ascii_case("bearer "))
1524 else {
1525 tracing::info!("Auth middleware: no Bearer token in request");
1526 return Err(unauthorized_response(&store.config.server_url));
1527 };
1528 let token = &h[7..];
1529
1530 let token_prefix = &token[..token.len().min(8)];
1531 let now = now_epoch();
1532 match store.token_store.get_access_token(token).await {
1533 Ok(Some(entry)) if now.saturating_sub(entry.created_at) < entry.expires_in_secs => {
1534 tracing::info!(
1535 "Auth middleware: token {}... valid (age={}s)",
1536 token_prefix,
1537 now.saturating_sub(entry.created_at)
1538 );
1539 let response = next.run(req).await;
1540 if response.status() == StatusCode::UNAUTHORIZED {
1545 tracing::info!(
1546 "Auth middleware: converting inner 401 to 404 (session not found, auth was valid)"
1547 );
1548 return Ok((StatusCode::NOT_FOUND, "Session not found").into_response());
1549 }
1550 Ok(response)
1551 }
1552 Ok(Some(entry)) => {
1553 tracing::warn!(
1554 "Auth middleware: token {}... EXPIRED (age={}s, max={}s)",
1555 token_prefix,
1556 now.saturating_sub(entry.created_at),
1557 entry.expires_in_secs
1558 );
1559 Err(unauthorized_response(&store.config.server_url))
1560 }
1561 Ok(None) => {
1562 tracing::warn!("Auth middleware: token {}... NOT FOUND", token_prefix,);
1563 Err(unauthorized_response(&store.config.server_url))
1564 }
1565 Err(e) => {
1566 tracing::error!("Auth middleware: token store error: {e}");
1567 Err(unauthorized_response(&store.config.server_url))
1568 }
1569 }
1570}
1571
1572fn store_error_response(description: &str, err: &StoreError) -> (StatusCode, Json<ErrorResponse>) {
1574 tracing::error!("Store error: {err}");
1575 let status = match err {
1576 StoreError::CapacityExceeded => StatusCode::TOO_MANY_REQUESTS,
1577 StoreError::Backend(_) => StatusCode::INTERNAL_SERVER_ERROR,
1578 };
1579 (
1580 status,
1581 Json(ErrorResponse {
1582 error: "server_error".into(),
1583 error_description: Some(description.into()),
1584 }),
1585 )
1586}
1587
1588fn unauthorized_response(server_url: &str) -> Response {
1589 (
1590 StatusCode::UNAUTHORIZED,
1591 [(
1592 header::WWW_AUTHENTICATE,
1593 format!(
1594 "Bearer realm=\"mcp-server\", resource_metadata=\"{server_url}/.well-known/oauth-protected-resource\""
1595 ),
1596 )],
1597 "Unauthorized",
1598 )
1599 .into_response()
1600}
1601
1602#[derive(Deserialize)]
1607struct PasskeyRegisterStartRequest {
1608 setup_token: Option<String>,
1609}
1610
1611#[derive(Serialize)]
1612struct PasskeyRegisterStartResponse {
1613 session_id: String,
1614 creation_options: CreationChallengeResponse,
1615}
1616
1617#[derive(Deserialize)]
1618struct PasskeyRegisterFinishRequest {
1619 session_id: String,
1620 credential: RegisterPublicKeyCredential,
1621}
1622
1623#[derive(Deserialize)]
1624struct PasskeyRegisterPageQuery {
1625 setup_token: Option<String>,
1626}
1627
1628async fn passkey_register_page<T: TokenStore, C: ClientStore, P: PasskeyStore>(
1629 State(store): State<AppState<T, C, P>>,
1630 axum::extract::Query(query): axum::extract::Query<PasskeyRegisterPageQuery>,
1631) -> Html<String> {
1632 let has_passkeys = store.has_passkeys().await;
1633 if has_passkeys {
1634 return Html(error_page(
1635 &store.config.app_name,
1636 "Passkey registration is locked. A passkey already exists. Delete passkeys.json and restart to reset.",
1637 ));
1638 }
1639 Html(register_page(
1640 &store.config.app_name,
1641 has_passkeys,
1642 query.setup_token.as_deref(),
1643 ))
1644}
1645
1646async fn passkey_register_start<T: TokenStore, C: ClientStore, P: PasskeyStore>(
1647 State(store): State<AppState<T, C, P>>,
1648 Json(body): Json<PasskeyRegisterStartRequest>,
1649) -> Result<Json<PasskeyRegisterStartResponse>, (StatusCode, Json<ErrorResponse>)> {
1650 let has_passkeys = store.has_passkeys().await;
1651
1652 if has_passkeys {
1653 return Err((
1656 StatusCode::FORBIDDEN,
1657 Json(ErrorResponse {
1658 error: "registration_locked".into(),
1659 error_description: Some(
1660 "Passkey registration is locked. A passkey already exists. Delete passkeys.json and restart to reset."
1661 .into(),
1662 ),
1663 }),
1664 ));
1665 }
1666 let expected = store.config.setup_token.as_deref().unwrap_or("");
1669 let provided = body.setup_token.as_deref().unwrap_or("");
1670 if expected.is_empty() || !constant_time_eq(provided, expected) {
1671 return Err((
1672 StatusCode::FORBIDDEN,
1673 Json(ErrorResponse {
1674 error: "invalid_setup_token".into(),
1675 error_description: Some("Invalid or missing setup token.".into()),
1676 }),
1677 ));
1678 }
1679
1680 let user_unique_id = [0u8; 16]; let existing = store.passkey_store.list_passkeys().await.map_err(|e| {
1682 tracing::error!("Passkey store error: {e}");
1683 (
1684 StatusCode::INTERNAL_SERVER_ERROR,
1685 Json(ErrorResponse {
1686 error: "server_error".into(),
1687 error_description: Some("Internal storage error".into()),
1688 }),
1689 )
1690 })?;
1691 let exclude: Vec<CredentialID> = existing.iter().map(|pk| pk.cred_id().clone()).collect();
1692
1693 let (ccr, reg_state) = store
1694 .webauthn
1695 .start_passkey_registration(
1696 Uuid::from_bytes(user_unique_id),
1697 "admin",
1698 "Admin",
1699 Some(exclude),
1700 )
1701 .map_err(|e| {
1703 tracing::error!("WebAuthn registration start failed: {e}");
1704 (
1705 StatusCode::INTERNAL_SERVER_ERROR,
1706 Json(ErrorResponse {
1707 error: "webauthn_error".into(),
1708 error_description: Some("Passkey registration could not be started.".into()),
1709 }),
1710 )
1711 })?;
1712
1713 let session_id = generate_token();
1714
1715 {
1717 let now = now_epoch();
1718 let mut states = store.registration_states.lock().await;
1719 states.retain(|_, (_, created_at)| {
1720 now.saturating_sub(*created_at) <= TRANSIENT_STATE_TTL_SECS
1721 });
1722 if states.len() >= store.config.capacity.max_registration_states {
1723 return Err((
1724 StatusCode::TOO_MANY_REQUESTS,
1725 Json(ErrorResponse {
1726 error: "capacity_exceeded".into(),
1727 error_description: Some("Too many pending registrations".into()),
1728 }),
1729 ));
1730 }
1731 states.insert(session_id.clone(), (reg_state, now));
1732 }
1733
1734 Ok(Json(PasskeyRegisterStartResponse {
1735 session_id,
1736 creation_options: ccr,
1737 }))
1738}
1739
1740async fn passkey_register_finish<T: TokenStore, C: ClientStore, P: PasskeyStore>(
1741 State(store): State<AppState<T, C, P>>,
1742 Json(body): Json<PasskeyRegisterFinishRequest>,
1743) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
1744 let reg_state = store
1745 .registration_states
1746 .lock()
1747 .await
1748 .remove(&body.session_id)
1749 .map(|(state, _timestamp)| state);
1750
1751 let Some(reg_state) = reg_state else {
1752 return Err((
1753 StatusCode::BAD_REQUEST,
1754 Json(ErrorResponse {
1755 error: "invalid_session".into(),
1756 error_description: Some("Unknown or expired registration session.".into()),
1757 }),
1758 ));
1759 };
1760
1761 let passkey = store
1762 .webauthn
1763 .finish_passkey_registration(&body.credential, ®_state)
1764 .map_err(|e| {
1766 tracing::error!("WebAuthn registration finish failed: {e}");
1767 (
1768 StatusCode::BAD_REQUEST,
1769 Json(ErrorResponse {
1770 error: "registration_failed".into(),
1771 error_description: Some("Passkey registration failed.".into()),
1772 }),
1773 )
1774 })?;
1775
1776 let added = store
1779 .passkey_store
1780 .add_passkey_if_none(passkey)
1781 .await
1782 .map_err(|e| {
1783 tracing::error!("Failed to save passkey: {e}");
1784 (
1785 StatusCode::INTERNAL_SERVER_ERROR,
1786 Json(ErrorResponse {
1787 error: "storage_error".into(),
1788 error_description: Some("Failed to persist passkey.".into()),
1789 }),
1790 )
1791 })?;
1792
1793 if !added {
1794 return Err((
1795 StatusCode::FORBIDDEN,
1796 Json(ErrorResponse {
1797 error: "registration_locked".into(),
1798 error_description: Some(
1799 "Passkey registration is locked. A passkey already exists.".into(),
1800 ),
1801 }),
1802 ));
1803 }
1804
1805 store.registration_states.lock().await.clear();
1808
1809 Ok(Json(serde_json::json!({ "ok": true })))
1810}
1811
1812#[derive(Deserialize)]
1817struct PasskeyAuthStartRequest {
1818 client_id: String,
1819 redirect_uri: String,
1820 state: Option<String>,
1821 code_challenge: String,
1822 code_challenge_method: String,
1823}
1824
1825#[derive(Serialize)]
1826struct PasskeyAuthStartResponse {
1827 session_id: String,
1828 request_options: RequestChallengeResponse,
1829}
1830
1831#[derive(Deserialize)]
1832struct PasskeyAuthFinishRequest {
1833 session_id: String,
1834 credential: PublicKeyCredential,
1835}
1836
1837#[derive(Serialize)]
1838struct PasskeyAuthFinishResponse {
1839 redirect_url: String,
1840}
1841
1842async fn passkey_auth_start<T: TokenStore, C: ClientStore, P: PasskeyStore>(
1843 State(store): State<AppState<T, C, P>>,
1844 Json(body): Json<PasskeyAuthStartRequest>,
1845) -> Result<Json<PasskeyAuthStartResponse>, (StatusCode, Json<ErrorResponse>)> {
1846 if !store.is_known_client(&body.client_id).await {
1848 return Err((
1849 StatusCode::BAD_REQUEST,
1850 Json(ErrorResponse {
1851 error: "invalid_client".into(),
1852 error_description: Some("Unknown client_id.".into()),
1853 }),
1854 ));
1855 }
1856 if !store
1857 .is_redirect_uri_allowed(&body.client_id, &body.redirect_uri)
1858 .await
1859 {
1860 return Err((
1861 StatusCode::BAD_REQUEST,
1862 Json(ErrorResponse {
1863 error: "invalid_redirect_uri".into(),
1864 error_description: Some("Redirect URI not allowed.".into()),
1865 }),
1866 ));
1867 }
1868 if body.code_challenge_method != "S256" || body.code_challenge.is_empty() {
1869 return Err((
1870 StatusCode::BAD_REQUEST,
1871 Json(ErrorResponse {
1872 error: "invalid_request".into(),
1873 error_description: Some("Invalid PKCE parameters.".into()),
1874 }),
1875 ));
1876 }
1877
1878 let passkeys = store.passkey_store.list_passkeys().await.map_err(|e| {
1879 tracing::error!("Passkey store error: {e}");
1880 (
1881 StatusCode::INTERNAL_SERVER_ERROR,
1882 Json(ErrorResponse {
1883 error: "server_error".into(),
1884 error_description: Some("Internal storage error".into()),
1885 }),
1886 )
1887 })?;
1888 if passkeys.is_empty() {
1889 return Err((
1890 StatusCode::BAD_REQUEST,
1891 Json(ErrorResponse {
1892 error: "no_passkeys".into(),
1893 error_description: Some("No passkeys registered.".into()),
1894 }),
1895 ));
1896 }
1897 let (rcr, auth_state) = store
1898 .webauthn
1899 .start_passkey_authentication(&passkeys)
1900 .map_err(|e| {
1902 tracing::error!("WebAuthn authentication start failed: {e}");
1903 (
1904 StatusCode::INTERNAL_SERVER_ERROR,
1905 Json(ErrorResponse {
1906 error: "webauthn_error".into(),
1907 error_description: Some("Passkey authentication could not be started.".into()),
1908 }),
1909 )
1910 })?;
1911
1912 let session_id = generate_token();
1913 let pending = PendingAuthApproval {
1914 client_id: body.client_id,
1915 redirect_uri: body.redirect_uri,
1916 state: body.state,
1917 code_challenge: body.code_challenge,
1918 code_challenge_method: body.code_challenge_method,
1919 };
1920
1921 {
1923 let now = now_epoch();
1924 let mut states = store.authentication_states.lock().await;
1925 states.retain(|_, (_, _, created_at)| {
1926 now.saturating_sub(*created_at) <= TRANSIENT_STATE_TTL_SECS
1927 });
1928 if states.len() >= store.config.capacity.max_authentication_states {
1929 return Err((
1930 StatusCode::TOO_MANY_REQUESTS,
1931 Json(ErrorResponse {
1932 error: "capacity_exceeded".into(),
1933 error_description: Some("Too many pending authentications".into()),
1934 }),
1935 ));
1936 }
1937 states.insert(session_id.clone(), (auth_state, pending, now));
1938 }
1939
1940 Ok(Json(PasskeyAuthStartResponse {
1941 session_id,
1942 request_options: rcr,
1943 }))
1944}
1945
1946async fn passkey_auth_finish<T: TokenStore, C: ClientStore, P: PasskeyStore>(
1947 State(store): State<AppState<T, C, P>>,
1948 Json(body): Json<PasskeyAuthFinishRequest>,
1949) -> Result<Response, (StatusCode, Json<ErrorResponse>)> {
1950 let entry = store
1951 .authentication_states
1952 .lock()
1953 .await
1954 .remove(&body.session_id);
1955
1956 let Some((auth_state, pending, _timestamp)) = entry else {
1957 return Err((
1958 StatusCode::BAD_REQUEST,
1959 Json(ErrorResponse {
1960 error: "invalid_session".into(),
1961 error_description: Some("Unknown or expired authentication session.".into()),
1962 }),
1963 ));
1964 };
1965
1966 let auth_result = store
1967 .webauthn
1968 .finish_passkey_authentication(&body.credential, &auth_state)
1969 .map_err(|e| {
1971 tracing::error!("WebAuthn authentication finish failed: {e}");
1972 (
1973 StatusCode::FORBIDDEN,
1974 Json(ErrorResponse {
1975 error: "authentication_failed".into(),
1976 error_description: Some("Passkey authentication failed.".into()),
1977 }),
1978 )
1979 })?;
1980
1981 if let Err(e) = store.passkey_store.update_passkey(&auth_result).await {
1983 tracing::error!("Failed to save updated passkey counters: {e}");
1984 }
1985
1986 let code = generate_token();
1988 let now = now_epoch();
1989
1990 store
1992 .token_store
1993 .store_auth_code(
1994 code.clone(),
1995 AuthCode {
1996 client_id: pending.client_id.clone(),
1997 redirect_uri: pending.redirect_uri.clone(),
1998 code_challenge: pending.code_challenge,
1999 created_at: now,
2000 },
2001 )
2002 .await
2003 .map_err(|e| {
2004 tracing::error!("Token store error: {e}");
2005 (
2006 StatusCode::TOO_MANY_REQUESTS,
2007 Json(ErrorResponse {
2008 error: "capacity_exceeded".into(),
2009 error_description: Some("Too many pending authorization codes".into()),
2010 }),
2011 )
2012 })?;
2013
2014 let mut redirect_url = Url::parse(&pending.redirect_uri).map_err(|_| {
2016 (
2017 StatusCode::BAD_REQUEST,
2018 Json(ErrorResponse {
2019 error: "invalid_redirect_uri".into(),
2020 error_description: Some("Invalid redirect URI.".into()),
2021 }),
2022 )
2023 })?;
2024 {
2025 let mut pairs = redirect_url.query_pairs_mut();
2026 pairs.append_pair("code", &code);
2027 if let Some(state) = &pending.state {
2028 pairs.append_pair("state", state);
2029 }
2030 }
2031
2032 let session_token = store.create_auth_session().await;
2034 let cookie_value = format!(
2035 "auth_session={}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age={}",
2036 session_token, store.config.token_lifetime_secs
2037 );
2038
2039 Ok((
2040 [(header::SET_COOKIE, cookie_value)],
2041 Json(PasskeyAuthFinishResponse {
2042 redirect_url: redirect_url.to_string(),
2043 }),
2044 )
2045 .into_response())
2046}
2047
2048const COMMON_STYLE: &str = include_str!("../templates/common.css");
2053
2054#[derive(Template)]
2055#[template(path = "error.html")]
2056struct ErrorTemplate<'a> {
2057 app_name: &'a str,
2058 style: &'a str,
2059 message: &'a str,
2060}
2061
2062#[derive(Template)]
2063#[template(path = "authorize_setup.html")]
2064struct AuthorizeSetupTemplate<'a> {
2065 app_name: &'a str,
2066 style: &'a str,
2067}
2068
2069#[derive(Template)]
2070#[template(path = "authorize.html")]
2071struct AuthorizeTemplate<'a> {
2072 app_name: &'a str,
2073 style: &'a str,
2074 params_json: &'a str,
2075}
2076
2077#[derive(Template)]
2078#[template(path = "register.html")]
2079struct RegisterTemplate<'a> {
2080 app_name: &'a str,
2081 style: &'a str,
2082 title: &'a str,
2083 prefill_token: &'a str,
2084 auto_start: bool,
2085}
2086
2087#[expect(
2088 clippy::too_many_arguments,
2089 reason = "each argument is an independent OAuth/template field; collecting them into a struct would just move the same count to the struct literal at the call site"
2090)]
2091fn authorize_page(
2092 app_name: &str,
2093 client_id: &str,
2094 redirect_uri: &str,
2095 state: &str,
2096 code_challenge: &str,
2097 code_challenge_method: &str,
2098 _scope: &str,
2099 has_passkeys: bool,
2100) -> String {
2101 if !has_passkeys {
2102 return AuthorizeSetupTemplate {
2103 app_name,
2104 style: COMMON_STYLE,
2105 }
2106 .render()
2107 .unwrap_or_default();
2108 }
2109
2110 #[expect(
2115 clippy::expect_used,
2116 reason = "serde_json::to_string on a statically-constructed json! literal containing only &str values is infallible modulo OOM"
2117 )]
2118 let params_json = serde_json::to_string(&serde_json::json!({
2119 "client_id": client_id,
2120 "redirect_uri": redirect_uri,
2121 "state": state,
2122 "code_challenge": code_challenge,
2123 "code_challenge_method": code_challenge_method,
2124 }))
2125 .expect("JSON serialization of string values is infallible");
2126 let params_json_safe = params_json.replace("</", "<\\/");
2127
2128 AuthorizeTemplate {
2129 app_name,
2130 style: COMMON_STYLE,
2131 params_json: ¶ms_json_safe,
2132 }
2133 .render()
2134 .unwrap_or_default()
2135}
2136
2137fn register_page(app_name: &str, has_passkeys: bool, prefill_token: Option<&str>) -> String {
2138 let title = if has_passkeys {
2139 "Register Additional Passkey"
2140 } else {
2141 "Register Your First Passkey"
2142 };
2143
2144 RegisterTemplate {
2145 app_name,
2146 style: COMMON_STYLE,
2147 title,
2148 prefill_token: prefill_token.unwrap_or_default(),
2149 auto_start: prefill_token.is_some(),
2150 }
2151 .render()
2152 .unwrap_or_default()
2153}
2154
2155fn error_page(app_name: &str, message: &str) -> String {
2156 ErrorTemplate {
2157 app_name,
2158 style: COMMON_STYLE,
2159 message,
2160 }
2161 .render()
2162 .unwrap_or_default()
2163}
2164
2165#[cfg(test)]
2170#[expect(
2171 clippy::unwrap_used,
2172 reason = "test module: invariants are established by the test fixtures themselves, so .unwrap() is idiomatic and a panic on violation is the desired test failure mode"
2173)]
2174mod tests {
2175 use super::*;
2176 use axum::routing::get as get_route;
2177 use axum_test::TestServer;
2178
2179 fn test_config(dir: &std::path::Path) -> OAuthConfig {
2180 OAuthConfig::with_defaults(
2181 "https://mcp.example.com".into(),
2182 "test-client-id".into(),
2183 "test-client-secret".into(),
2184 "Test App".into(),
2185 dir.join("passkeys.json"),
2186 Some("setup-token-123".into()),
2187 )
2188 }
2189
2190 fn build_test_app(dir: &std::path::Path) -> Router {
2191 build_test_app_with_config(test_config(dir))
2192 }
2193
2194 fn build_test_app_with_config(config: OAuthConfig) -> Router {
2195 let protected = Router::new().route("/mcp", get_route(|| async { "protected content" }));
2196 let (token_store, client_store, passkey_store) = create_default_stores(&config);
2197 build_oauth_router_with_stores(protected, config, token_store, client_store, passkey_store)
2198 }
2199
2200 #[test]
2203 fn test_constant_time_eq_same() {
2204 assert!(constant_time_eq("hello", "hello"));
2205 }
2206
2207 #[test]
2208 fn test_constant_time_eq_different() {
2209 assert!(!constant_time_eq("hello", "world"));
2210 }
2211
2212 #[test]
2213 fn test_constant_time_eq_different_lengths() {
2214 assert!(!constant_time_eq("short", "longer string"));
2215 }
2216
2217 #[test]
2218 fn test_constant_time_eq_empty() {
2219 assert!(constant_time_eq("", ""));
2220 }
2221
2222 #[test]
2223 fn test_generate_token_length() {
2224 let token = generate_token();
2225 assert_eq!(token.len(), 43);
2227 }
2228
2229 #[test]
2230 fn test_generate_token_uniqueness() {
2231 let t1 = generate_token();
2232 let t2 = generate_token();
2233 assert_ne!(t1, t2);
2234 }
2235
2236 #[test]
2237 fn test_generate_token_is_base64url() {
2238 let token = generate_token();
2239 assert!(URL_SAFE_NO_PAD.decode(&token).is_ok());
2240 }
2241
2242 #[test]
2243 fn test_extract_domain_valid() {
2244 assert_eq!(
2245 extract_domain("https://mcp.example.com").unwrap(),
2246 "mcp.example.com"
2247 );
2248 }
2249
2250 #[test]
2251 fn test_extract_domain_with_port() {
2252 assert_eq!(
2253 extract_domain("https://mcp.example.com:8443").unwrap(),
2254 "mcp.example.com"
2255 );
2256 }
2257
2258 #[test]
2259 fn test_extract_domain_invalid() {
2260 assert!(extract_domain("not a url").is_err());
2261 }
2262
2263 #[test]
2264 fn test_now_epoch_reasonable() {
2265 let now = now_epoch();
2266 assert!(now > 1_704_067_200);
2268 }
2269
2270 #[test]
2273 fn test_config_defaults() {
2274 let cfg = OAuthConfig::with_defaults(
2275 "https://example.com".into(),
2276 "id".into(),
2277 "secret".into(),
2278 "App".into(),
2279 PathBuf::from("pk.json"),
2280 None,
2281 );
2282 assert_eq!(cfg.token_lifetime_secs, 86400);
2283 assert_eq!(cfg.code_lifetime_secs, 300);
2284 assert!(cfg.setup_token.is_none());
2285 }
2286
2287 #[test]
2288 #[should_panic(expected = "client_id must not be empty")]
2289 fn test_config_empty_client_id_panics() {
2290 let _ = OAuthConfig::with_defaults(
2291 "https://example.com".into(),
2292 String::new(),
2293 "secret".into(),
2294 "App".into(),
2295 PathBuf::from("pk.json"),
2296 None,
2297 );
2298 }
2299
2300 #[test]
2301 #[should_panic(expected = "client_secret must not be empty")]
2302 fn test_config_empty_client_secret_panics() {
2303 let _ = OAuthConfig::with_defaults(
2304 "https://example.com".into(),
2305 "id".into(),
2306 String::new(),
2307 "App".into(),
2308 PathBuf::from("pk.json"),
2309 None,
2310 );
2311 }
2312
2313 #[test]
2314 #[should_panic(expected = "passkey_store_path must not contain '..' components")]
2315 fn test_config_rejects_path_traversal() {
2316 let _ = OAuthConfig::with_defaults(
2317 "https://example.com".into(),
2318 "id".into(),
2319 "secret".into(),
2320 "App".into(),
2321 PathBuf::from("/data/../etc/passkeys.json"),
2322 None,
2323 );
2324 }
2325
2326 #[test]
2329 fn test_builder_defaults_match_with_defaults() {
2330 let from_defaults = OAuthConfig::with_defaults(
2331 "https://example.com".into(),
2332 "id".into(),
2333 "secret".into(),
2334 "App".into(),
2335 PathBuf::from("pk.json"),
2336 None,
2337 );
2338 let from_builder = OAuthConfig::builder(
2339 "https://example.com".into(),
2340 "id".into(),
2341 "secret".into(),
2342 "App".into(),
2343 PathBuf::from("pk.json"),
2344 )
2345 .build()
2346 .unwrap();
2347
2348 assert_eq!(
2349 from_defaults.token_lifetime_secs,
2350 from_builder.token_lifetime_secs
2351 );
2352 assert_eq!(
2353 from_defaults.code_lifetime_secs,
2354 from_builder.code_lifetime_secs
2355 );
2356 assert_eq!(
2357 from_defaults.allowed_redirect_uris,
2358 from_builder.allowed_redirect_uris
2359 );
2360 assert_eq!(
2361 from_defaults.rate_limits.strict,
2362 from_builder.rate_limits.strict
2363 );
2364 assert_eq!(
2365 from_defaults.rate_limits.moderate,
2366 from_builder.rate_limits.moderate
2367 );
2368 assert_eq!(
2369 from_defaults.rate_limits.lenient,
2370 from_builder.rate_limits.lenient
2371 );
2372 assert_eq!(
2373 from_defaults.capacity.max_registration_states,
2374 from_builder.capacity.max_registration_states
2375 );
2376 assert_eq!(
2377 from_defaults.capacity.max_authentication_states,
2378 from_builder.capacity.max_authentication_states
2379 );
2380 assert_eq!(
2381 from_defaults.capacity.max_access_tokens,
2382 from_builder.capacity.max_access_tokens
2383 );
2384 assert_eq!(
2385 from_defaults.capacity.max_refresh_tokens,
2386 from_builder.capacity.max_refresh_tokens
2387 );
2388 assert_eq!(
2389 from_defaults.capacity.max_auth_codes,
2390 from_builder.capacity.max_auth_codes
2391 );
2392 assert_eq!(
2393 from_defaults.capacity.max_registered_clients,
2394 from_builder.capacity.max_registered_clients
2395 );
2396 assert_eq!(from_defaults.scopes, from_builder.scopes);
2397 }
2398
2399 #[test]
2400 fn test_builder_empty_client_id_fails() {
2401 let result = OAuthConfig::builder(
2402 "https://example.com".into(),
2403 String::new(),
2404 "secret".into(),
2405 "App".into(),
2406 PathBuf::from("pk.json"),
2407 )
2408 .build();
2409 assert!(matches!(result, Err(OAuthConfigError::EmptyClientId)));
2410 }
2411
2412 #[test]
2413 fn test_builder_empty_client_secret_fails() {
2414 let result = OAuthConfig::builder(
2415 "https://example.com".into(),
2416 "id".into(),
2417 String::new(),
2418 "App".into(),
2419 PathBuf::from("pk.json"),
2420 )
2421 .build();
2422 assert!(matches!(result, Err(OAuthConfigError::EmptyClientSecret)));
2423 }
2424
2425 #[test]
2426 fn test_builder_path_traversal_fails() {
2427 let result = OAuthConfig::builder(
2428 "https://example.com".into(),
2429 "id".into(),
2430 "secret".into(),
2431 "App".into(),
2432 PathBuf::from("/data/../etc/passkeys.json"),
2433 )
2434 .build();
2435 assert!(matches!(result, Err(OAuthConfigError::PathTraversal)));
2436 }
2437
2438 #[test]
2439 fn test_builder_zero_rate_limit_fails() {
2440 let result = OAuthConfig::builder(
2441 "https://example.com".into(),
2442 "id".into(),
2443 "secret".into(),
2444 "App".into(),
2445 PathBuf::from("pk.json"),
2446 )
2447 .rate_limits(RateLimitConfig {
2448 strict: 0,
2449 moderate: 30,
2450 lenient: 60,
2451 })
2452 .build();
2453 assert!(matches!(result, Err(OAuthConfigError::ZeroRateLimit)));
2454 }
2455
2456 #[test]
2457 fn test_builder_empty_scopes_fails() {
2458 let result = OAuthConfig::builder(
2459 "https://example.com".into(),
2460 "id".into(),
2461 "secret".into(),
2462 "App".into(),
2463 PathBuf::from("pk.json"),
2464 )
2465 .scopes(vec![])
2466 .build();
2467 assert!(matches!(result, Err(OAuthConfigError::EmptyScopes)));
2468 }
2469
2470 #[test]
2471 fn test_builder_custom_redirect_uris_replaces() {
2472 let cfg = OAuthConfig::builder(
2473 "https://example.com".into(),
2474 "id".into(),
2475 "secret".into(),
2476 "App".into(),
2477 PathBuf::from("pk.json"),
2478 )
2479 .allowed_redirect_uris(vec!["https://custom.example.com/cb".to_owned()])
2480 .build()
2481 .unwrap();
2482 assert_eq!(
2483 cfg.allowed_redirect_uris,
2484 vec!["https://custom.example.com/cb"]
2485 );
2486 }
2487
2488 #[test]
2489 fn test_builder_add_redirect_uri_appends() {
2490 let cfg = OAuthConfig::builder(
2491 "https://example.com".into(),
2492 "id".into(),
2493 "secret".into(),
2494 "App".into(),
2495 PathBuf::from("pk.json"),
2496 )
2497 .add_redirect_uri("https://custom.example.com/cb")
2498 .build()
2499 .unwrap();
2500 assert_eq!(
2501 cfg.allowed_redirect_uris.len(),
2502 default_redirect_uris().len() + 1
2503 );
2504 assert!(
2505 cfg.allowed_redirect_uris
2506 .contains(&"https://claude.ai/api/mcp/auth_callback".to_owned())
2507 );
2508 assert!(
2509 cfg.allowed_redirect_uris
2510 .contains(&"https://custom.example.com/cb".to_owned())
2511 );
2512 }
2513
2514 #[test]
2515 fn test_builder_custom_scopes() {
2516 let cfg = OAuthConfig::builder(
2517 "https://example.com".into(),
2518 "id".into(),
2519 "secret".into(),
2520 "App".into(),
2521 PathBuf::from("pk.json"),
2522 )
2523 .scopes(vec!["read".to_owned(), "write".to_owned()])
2524 .build()
2525 .unwrap();
2526 assert_eq!(cfg.scopes, vec!["read", "write"]);
2527 }
2528
2529 #[test]
2530 fn test_builder_add_scope_appends() {
2531 let cfg = OAuthConfig::builder(
2532 "https://example.com".into(),
2533 "id".into(),
2534 "secret".into(),
2535 "App".into(),
2536 PathBuf::from("pk.json"),
2537 )
2538 .add_scope("admin")
2539 .build()
2540 .unwrap();
2541 assert_eq!(cfg.scopes, vec!["mcp:tools", "admin"]);
2542 }
2543
2544 #[test]
2545 fn test_builder_zero_max_access_tokens_fails() {
2546 let result = OAuthConfig::builder(
2547 "https://example.com".into(),
2548 "id".into(),
2549 "secret".into(),
2550 "App".into(),
2551 PathBuf::from("pk.json"),
2552 )
2553 .max_access_tokens(0)
2554 .build();
2555 assert!(matches!(result, Err(OAuthConfigError::ZeroCapacity)));
2556 }
2557
2558 #[test]
2559 fn test_builder_some_zero_max_registered_clients_fails() {
2560 let result = OAuthConfig::builder(
2561 "https://example.com".into(),
2562 "id".into(),
2563 "secret".into(),
2564 "App".into(),
2565 PathBuf::from("pk.json"),
2566 )
2567 .max_registered_clients(Some(0))
2568 .build();
2569 assert!(matches!(result, Err(OAuthConfigError::ZeroCapacity)));
2570 }
2571
2572 #[test]
2573 fn test_builder_none_max_registered_clients_allowed() {
2574 let cfg = OAuthConfig::builder(
2575 "https://example.com".into(),
2576 "id".into(),
2577 "secret".into(),
2578 "App".into(),
2579 PathBuf::from("pk.json"),
2580 )
2581 .max_registered_clients(None)
2582 .build()
2583 .unwrap();
2584 assert_eq!(cfg.capacity.max_registered_clients, None);
2585 }
2586
2587 #[test]
2588 fn test_oauth_config_error_display_all_variants() {
2589 assert!(
2592 OAuthConfigError::EmptyClientId
2593 .to_string()
2594 .contains("client_id")
2595 );
2596 assert!(
2597 OAuthConfigError::EmptyClientSecret
2598 .to_string()
2599 .contains("client_secret")
2600 );
2601 assert!(
2602 OAuthConfigError::PathTraversal
2603 .to_string()
2604 .contains("passkey_store_path")
2605 );
2606 assert!(
2607 OAuthConfigError::ZeroRateLimit
2608 .to_string()
2609 .contains("rate limit")
2610 );
2611 assert!(OAuthConfigError::EmptyScopes.to_string().contains("scopes"));
2612 assert!(
2613 OAuthConfigError::ZeroCapacity
2614 .to_string()
2615 .contains("capacity")
2616 );
2617 }
2618
2619 #[tokio::test]
2622 async fn test_health_endpoint() {
2623 let dir = tempfile::tempdir().unwrap();
2624 let server = TestServer::new(build_test_app(dir.path()));
2625
2626 let resp = server.get("/health").await;
2627 resp.assert_status_ok();
2628 resp.assert_text("ok");
2629 }
2630
2631 #[tokio::test]
2632 async fn test_protected_resource_metadata() {
2633 let dir = tempfile::tempdir().unwrap();
2634 let server = TestServer::new(build_test_app(dir.path()));
2635
2636 let resp = server.get("/.well-known/oauth-protected-resource").await;
2637 resp.assert_status_ok();
2638 let body: serde_json::Value = resp.json();
2639 assert_eq!(body["resource"], "https://mcp.example.com");
2640 assert_eq!(
2641 body["bearer_methods_supported"],
2642 serde_json::json!(["header"])
2643 );
2644 }
2645
2646 #[tokio::test]
2647 async fn test_authorization_server_metadata() {
2648 let dir = tempfile::tempdir().unwrap();
2649 let server = TestServer::new(build_test_app(dir.path()));
2650
2651 let resp = server.get("/.well-known/oauth-authorization-server").await;
2652 resp.assert_status_ok();
2653 let body: serde_json::Value = resp.json();
2654 assert_eq!(body["issuer"], "https://mcp.example.com");
2655 assert_eq!(
2656 body["authorization_endpoint"],
2657 "https://mcp.example.com/authorize"
2658 );
2659 assert_eq!(body["token_endpoint"], "https://mcp.example.com/token");
2660 assert_eq!(
2661 body["code_challenge_methods_supported"],
2662 serde_json::json!(["S256"])
2663 );
2664 assert!(body["registration_endpoint"].is_string());
2666 }
2667
2668 #[tokio::test]
2669 async fn test_protected_route_requires_auth() {
2670 let dir = tempfile::tempdir().unwrap();
2671 let server = TestServer::new(build_test_app(dir.path()));
2672
2673 let resp = server.get("/mcp").await;
2674 resp.assert_status(StatusCode::UNAUTHORIZED);
2675 let www_auth = resp.header("WWW-Authenticate");
2677 assert!(www_auth.to_str().unwrap().contains("Bearer"));
2678 }
2679
2680 #[tokio::test]
2681 async fn test_protected_route_invalid_token() {
2682 let dir = tempfile::tempdir().unwrap();
2683 let server = TestServer::new(build_test_app(dir.path()));
2684
2685 let resp = server
2686 .get("/mcp")
2687 .add_header(
2688 header::AUTHORIZATION,
2689 "Bearer invalid-token"
2690 .parse::<axum::http::HeaderValue>()
2691 .unwrap(),
2692 )
2693 .await;
2694 resp.assert_status(StatusCode::UNAUTHORIZED);
2695 }
2696
2697 #[tokio::test]
2698 async fn test_token_invalid_client() {
2699 let dir = tempfile::tempdir().unwrap();
2700 let server = TestServer::new(build_test_app(dir.path()));
2701
2702 let resp = server
2703 .post("/token")
2704 .form(&serde_json::json!({
2705 "grant_type": "authorization_code",
2706 "client_id": "wrong",
2707 "client_secret": "wrong",
2708 "code": "abc",
2709 "redirect_uri": "https://example.com",
2710 "code_verifier": "x"
2711 }))
2712 .await;
2713 resp.assert_status(StatusCode::UNAUTHORIZED);
2714 let body: serde_json::Value = resp.json();
2715 assert_eq!(body["error"], "invalid_client");
2716 }
2717
2718 #[tokio::test]
2719 async fn test_token_unsupported_grant_type() {
2720 let dir = tempfile::tempdir().unwrap();
2721 let server = TestServer::new(build_test_app(dir.path()));
2722
2723 let resp = server
2724 .post("/token")
2725 .form(&serde_json::json!({
2726 "grant_type": "client_credentials",
2727 "client_id": "test-client-id",
2728 "client_secret": "test-client-secret"
2729 }))
2730 .await;
2731 resp.assert_status(StatusCode::BAD_REQUEST);
2732 let body: serde_json::Value = resp.json();
2733 assert_eq!(body["error"], "unsupported_grant_type");
2734 }
2735
2736 #[tokio::test]
2737 async fn test_authorize_missing_params() {
2738 let dir = tempfile::tempdir().unwrap();
2739 let server = TestServer::new(build_test_app(dir.path()));
2740
2741 let resp = server.get("/authorize").await;
2743 resp.assert_status(StatusCode::BAD_REQUEST);
2744 }
2745
2746 #[tokio::test]
2747 async fn test_authorize_invalid_response_type() {
2748 let dir = tempfile::tempdir().unwrap();
2749 let server = TestServer::new(build_test_app(dir.path()));
2750
2751 let resp = server
2752 .get("/authorize?response_type=token&client_id=test-client-id&redirect_uri=https://claude.ai/api/mcp/auth_callback&code_challenge=abc&code_challenge_method=S256")
2753 .await;
2754 resp.assert_status(StatusCode::BAD_REQUEST);
2755 }
2756
2757 #[tokio::test]
2758 async fn test_authorize_unknown_client() {
2759 let dir = tempfile::tempdir().unwrap();
2760 let server = TestServer::new(build_test_app(dir.path()));
2761
2762 let resp = server
2763 .get("/authorize?response_type=code&client_id=unknown&redirect_uri=https://claude.ai/api/mcp/auth_callback&code_challenge=abc&code_challenge_method=S256")
2764 .await;
2765 resp.assert_status(StatusCode::BAD_REQUEST);
2766 }
2767
2768 #[tokio::test]
2769 async fn test_authorize_disallowed_redirect_uri() {
2770 let dir = tempfile::tempdir().unwrap();
2771 let server = TestServer::new(build_test_app(dir.path()));
2772
2773 let resp = server
2774 .get("/authorize?response_type=code&client_id=test-client-id&redirect_uri=https://evil.com/callback&code_challenge=abc&code_challenge_method=S256")
2775 .await;
2776 resp.assert_status(StatusCode::BAD_REQUEST);
2777 }
2778
2779 #[tokio::test]
2780 async fn test_authorize_wrong_code_challenge_method() {
2781 let dir = tempfile::tempdir().unwrap();
2782 let server = TestServer::new(build_test_app(dir.path()));
2783
2784 let resp = server
2785 .get("/authorize?response_type=code&client_id=test-client-id&redirect_uri=https://claude.ai/api/mcp/auth_callback&code_challenge=abc&code_challenge_method=plain")
2786 .await;
2787 resp.assert_status(StatusCode::BAD_REQUEST);
2788 }
2789
2790 #[tokio::test]
2791 async fn test_authorize_valid_params_shows_setup_page() {
2792 let dir = tempfile::tempdir().unwrap();
2793 let server = TestServer::new(build_test_app(dir.path()));
2794
2795 let resp = server
2797 .get("/authorize?response_type=code&client_id=test-client-id&redirect_uri=https://claude.ai/api/mcp/auth_callback&code_challenge=abc&code_challenge_method=S256")
2798 .await;
2799 resp.assert_status_ok();
2800 let body = resp.text();
2801 assert!(
2802 body.contains("setup")
2803 || body.contains("Setup")
2804 || body.contains("register")
2805 || body.contains("Register")
2806 );
2807 }
2808
2809 #[tokio::test]
2810 async fn test_passkey_register_without_setup_token() {
2811 let dir = tempfile::tempdir().unwrap();
2812 let server = TestServer::new(build_test_app(dir.path()));
2813
2814 let resp = server
2815 .post("/passkey/register/start")
2816 .json(&serde_json::json!({}))
2817 .await;
2818 resp.assert_status(StatusCode::FORBIDDEN);
2819 let body: serde_json::Value = resp.json();
2820 assert_eq!(body["error"], "invalid_setup_token");
2821 }
2822
2823 #[tokio::test]
2824 async fn test_passkey_register_wrong_setup_token() {
2825 let dir = tempfile::tempdir().unwrap();
2826 let server = TestServer::new(build_test_app(dir.path()));
2827
2828 let resp = server
2829 .post("/passkey/register/start")
2830 .json(&serde_json::json!({ "setup_token": "wrong-token" }))
2831 .await;
2832 resp.assert_status(StatusCode::FORBIDDEN);
2833 let body: serde_json::Value = resp.json();
2834 assert_eq!(body["error"], "invalid_setup_token");
2835 }
2836
2837 #[tokio::test]
2838 async fn test_passkey_register_valid_setup_token() {
2839 let dir = tempfile::tempdir().unwrap();
2840 let server = TestServer::new(build_test_app(dir.path()));
2841
2842 let resp = server
2843 .post("/passkey/register/start")
2844 .json(&serde_json::json!({ "setup_token": "setup-token-123" }))
2845 .await;
2846 resp.assert_status_ok();
2847 let body: serde_json::Value = resp.json();
2848 assert!(body["session_id"].is_string());
2849 assert!(body["creation_options"].is_object());
2850 }
2851
2852 #[tokio::test]
2853 async fn test_register_client_first_time() {
2854 let dir = tempfile::tempdir().unwrap();
2855 let server = TestServer::new(build_test_app(dir.path()));
2856
2857 let resp = server
2858 .post("/register")
2859 .json(&serde_json::json!({
2860 "client_name": "My Client",
2861 "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"],
2862 "grant_types": ["authorization_code"],
2863 "response_types": ["code"],
2864 "token_endpoint_auth_method": "client_secret_post"
2865 }))
2866 .await;
2867 resp.assert_status_ok();
2868 let body: serde_json::Value = resp.json();
2869 assert!(body["client_id"].is_string());
2870 assert!(body["client_secret"].is_string());
2871 assert_eq!(body["client_name"], "My Client");
2872 }
2873
2874 #[tokio::test]
2875 async fn test_register_client_locks_after_first() {
2876 let dir = tempfile::tempdir().unwrap();
2877 let server = TestServer::new(build_test_app(dir.path()));
2878
2879 let resp = server
2881 .post("/register")
2882 .json(&serde_json::json!({
2883 "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]
2884 }))
2885 .await;
2886 resp.assert_status_ok();
2887
2888 let resp = server
2890 .post("/register")
2891 .json(&serde_json::json!({
2892 "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]
2893 }))
2894 .await;
2895 resp.assert_status(StatusCode::FORBIDDEN);
2896 let body: serde_json::Value = resp.json();
2897 assert_eq!(body["error"], "registration_locked");
2898 }
2899
2900 #[tokio::test]
2901 async fn test_register_client_invalid_redirect_uri() {
2902 let dir = tempfile::tempdir().unwrap();
2903 let server = TestServer::new(build_test_app(dir.path()));
2904
2905 let resp = server
2906 .post("/register")
2907 .json(&serde_json::json!({
2908 "redirect_uris": ["https://evil.com/callback"]
2909 }))
2910 .await;
2911 resp.assert_status(StatusCode::BAD_REQUEST);
2912 let body: serde_json::Value = resp.json();
2913 assert_eq!(body["error"], "invalid_redirect_uri");
2914 }
2915
2916 #[tokio::test]
2917 async fn test_security_headers_present() {
2918 let dir = tempfile::tempdir().unwrap();
2919 let server = TestServer::new(build_test_app(dir.path()));
2920
2921 let resp = server.get("/health").await;
2922 resp.assert_status_ok();
2923 assert_eq!(resp.header("X-Frame-Options").to_str().unwrap(), "DENY");
2924 assert_eq!(
2925 resp.header("X-Content-Type-Options").to_str().unwrap(),
2926 "nosniff"
2927 );
2928 assert_eq!(
2929 resp.header("Referrer-Policy").to_str().unwrap(),
2930 "no-referrer"
2931 );
2932 assert!(
2933 resp.header("Content-Security-Policy")
2934 .to_str()
2935 .unwrap()
2936 .contains("default-src 'self'")
2937 );
2938 assert!(
2939 resp.header("Permissions-Policy")
2940 .to_str()
2941 .unwrap()
2942 .contains("camera=()")
2943 );
2944 }
2945
2946 #[tokio::test]
2947 async fn test_pkce_code_verifier_too_short() {
2948 let dir = tempfile::tempdir().unwrap();
2949 let server = TestServer::new(build_test_app(dir.path()));
2950
2951 let resp = server
2952 .post("/token")
2953 .form(&serde_json::json!({
2954 "grant_type": "authorization_code",
2955 "client_id": "test-client-id",
2956 "client_secret": "test-client-secret",
2957 "code": "abc",
2958 "redirect_uri": "https://example.com",
2959 "code_verifier": "tooshort"
2960 }))
2961 .await;
2962 resp.assert_status(StatusCode::BAD_REQUEST);
2963 let body: serde_json::Value = resp.json();
2964 assert_eq!(body["error"], "invalid_grant");
2965 assert!(
2966 body["error_description"]
2967 .as_str()
2968 .unwrap()
2969 .contains("43-128")
2970 );
2971 }
2972
2973 #[test]
2976 fn test_atomic_write_creates_file() {
2977 let dir = tempfile::tempdir().unwrap();
2978 let path = dir.path().join("test.json");
2979 store::json_file::atomic_write(&path, b"hello").unwrap();
2980 assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello");
2981 }
2982
2983 #[test]
2984 fn test_atomic_write_creates_parent_dirs() {
2985 let dir = tempfile::tempdir().unwrap();
2986 let path = dir.path().join("sub").join("dir").join("test.json");
2987 store::json_file::atomic_write(&path, b"nested").unwrap();
2988 assert_eq!(std::fs::read_to_string(&path).unwrap(), "nested");
2989 }
2990
2991 #[test]
2992 fn test_load_passkeys_missing_file() {
2993 let passkeys =
2994 store::json_file::load_passkeys(std::path::Path::new("/nonexistent/passkeys.json"));
2995 assert!(passkeys.is_empty());
2996 }
2997
2998 #[test]
2999 fn test_load_tokens_missing_file() {
3000 let caps = store::json_file::StoreCaps {
3001 max_access_tokens: 10,
3002 max_refresh_tokens: 10,
3003 max_auth_codes: 10,
3004 max_registered_clients: Some(1),
3005 };
3006 let (_, _, summary) = store::json_file::create_json_file_stores(
3007 std::path::Path::new("/nonexistent/passkeys.json"),
3008 caps,
3009 );
3010 assert_eq!(summary.access_tokens, 0);
3011 assert_eq!(summary.refresh_tokens, 0);
3012 assert_eq!(summary.registered_clients, 0);
3013 }
3014
3015 #[test]
3018 fn test_error_page_renders() {
3019 let html = error_page("Test App", "Something went wrong");
3020 assert!(html.contains("Test App"));
3021 assert!(html.contains("Something went wrong"));
3022 }
3023
3024 #[test]
3025 fn test_authorize_page_no_passkeys_shows_setup() {
3026 let html = authorize_page("App", "cid", "https://r.com", "", "ch", "S256", "", false);
3027 assert!(html.contains("App"));
3029 }
3030
3031 #[test]
3032 fn test_authorize_page_with_passkeys_embeds_params() {
3033 let html = authorize_page("App", "cid", "https://r.com", "st", "ch", "S256", "", true);
3034 assert!(html.contains("App"));
3035 assert!(html.contains("cid"));
3037 }
3038
3039 #[test]
3040 fn test_authorize_page_xss_prevention() {
3041 let html = authorize_page(
3043 "App",
3044 "</script><script>alert(1)",
3045 "https://r.com",
3046 "",
3047 "ch",
3048 "S256",
3049 "",
3050 true,
3051 );
3052 assert!(!html.contains("</script><script>"));
3053 assert!(html.contains("<\\/script>"));
3054 }
3055
3056 #[test]
3057 fn test_register_page_renders() {
3058 let html = register_page("App", false, Some("tok123"));
3059 assert!(html.contains("App"));
3060 assert!(html.contains("tok123"));
3061 }
3062
3063 #[tokio::test]
3066 async fn test_custom_redirect_uri_accepted() {
3067 let dir = tempfile::tempdir().unwrap();
3068 let config = OAuthConfig::builder(
3069 "https://mcp.example.com".into(),
3070 "test-client-id".into(),
3071 "test-client-secret".into(),
3072 "Test App".into(),
3073 dir.path().join("passkeys.json"),
3074 )
3075 .setup_token("setup-token-123")
3076 .add_redirect_uri("https://custom.example.com/callback")
3077 .build()
3078 .unwrap();
3079 let server = TestServer::new(build_test_app_with_config(config));
3080
3081 let resp = server
3082 .post("/register")
3083 .json(&serde_json::json!({
3084 "redirect_uris": ["https://custom.example.com/callback"]
3085 }))
3086 .await;
3087 resp.assert_status_ok();
3088 }
3089
3090 #[tokio::test]
3091 async fn test_default_redirect_uri_rejected_when_replaced() {
3092 let dir = tempfile::tempdir().unwrap();
3093 let config = OAuthConfig::builder(
3094 "https://mcp.example.com".into(),
3095 "test-client-id".into(),
3096 "test-client-secret".into(),
3097 "Test App".into(),
3098 dir.path().join("passkeys.json"),
3099 )
3100 .setup_token("setup-token-123")
3101 .allowed_redirect_uris(vec!["https://custom.example.com/callback".to_owned()])
3102 .build()
3103 .unwrap();
3104 let server = TestServer::new(build_test_app_with_config(config));
3105
3106 let resp = server
3107 .post("/register")
3108 .json(&serde_json::json!({
3109 "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]
3110 }))
3111 .await;
3112 resp.assert_status(StatusCode::BAD_REQUEST);
3113 }
3114
3115 #[tokio::test]
3116 async fn test_custom_scope_in_metadata() {
3117 let dir = tempfile::tempdir().unwrap();
3118 let config = OAuthConfig::builder(
3119 "https://mcp.example.com".into(),
3120 "test-client-id".into(),
3121 "test-client-secret".into(),
3122 "Test App".into(),
3123 dir.path().join("passkeys.json"),
3124 )
3125 .setup_token("setup-token-123")
3126 .scopes(vec!["read".to_owned(), "write".to_owned()])
3127 .build()
3128 .unwrap();
3129 let server = TestServer::new(build_test_app_with_config(config));
3130
3131 let resp = server.get("/.well-known/oauth-authorization-server").await;
3132 resp.assert_status_ok();
3133 let body: serde_json::Value = resp.json();
3134 assert_eq!(
3135 body["scopes_supported"],
3136 serde_json::json!(["read", "write"])
3137 );
3138 }
3139
3140 #[tokio::test]
3141 async fn test_metadata_advertises_registration_endpoint_under_cap() {
3142 let dir = tempfile::tempdir().unwrap();
3146 let config = OAuthConfig::builder(
3147 "https://mcp.example.com".into(),
3148 "test-client-id".into(),
3149 "test-client-secret".into(),
3150 "Test App".into(),
3151 dir.path().join("passkeys.json"),
3152 )
3153 .setup_token("setup-token-123")
3154 .max_registered_clients(Some(2))
3155 .build()
3156 .unwrap();
3157 let server = TestServer::new(build_test_app_with_config(config));
3158
3159 server
3160 .post("/register")
3161 .json(&serde_json::json!({
3162 "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]
3163 }))
3164 .await
3165 .assert_status_ok();
3166
3167 let resp = server.get("/.well-known/oauth-authorization-server").await;
3168 resp.assert_status_ok();
3169 let body: serde_json::Value = resp.json();
3170 assert!(
3171 body["registration_endpoint"].is_string(),
3172 "registration_endpoint should still be advertised when the cap permits more clients"
3173 );
3174 }
3175
3176 #[tokio::test]
3177 async fn test_metadata_hides_registration_endpoint_when_cap_reached() {
3178 let dir = tempfile::tempdir().unwrap();
3182 let config = OAuthConfig::builder(
3183 "https://mcp.example.com".into(),
3184 "test-client-id".into(),
3185 "test-client-secret".into(),
3186 "Test App".into(),
3187 dir.path().join("passkeys.json"),
3188 )
3189 .setup_token("setup-token-123")
3190 .max_registered_clients(Some(1))
3191 .build()
3192 .unwrap();
3193 let server = TestServer::new(build_test_app_with_config(config));
3194
3195 server
3196 .post("/register")
3197 .json(&serde_json::json!({
3198 "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]
3199 }))
3200 .await
3201 .assert_status_ok();
3202
3203 let resp = server.get("/.well-known/oauth-authorization-server").await;
3204 resp.assert_status_ok();
3205 let body: serde_json::Value = resp.json();
3206 assert!(
3207 body["registration_endpoint"].is_null(),
3208 "registration_endpoint should be hidden once the cap is reached"
3209 );
3210 }
3211
3212 #[tokio::test]
3213 async fn test_metadata_always_advertises_registration_when_cap_is_none() {
3214 let dir = tempfile::tempdir().unwrap();
3217 let config = OAuthConfig::builder(
3218 "https://mcp.example.com".into(),
3219 "test-client-id".into(),
3220 "test-client-secret".into(),
3221 "Test App".into(),
3222 dir.path().join("passkeys.json"),
3223 )
3224 .setup_token("setup-token-123")
3225 .max_registered_clients(None)
3226 .build()
3227 .unwrap();
3228 let server = TestServer::new(build_test_app_with_config(config));
3229
3230 for _ in 0..3 {
3231 server
3232 .post("/register")
3233 .json(&serde_json::json!({
3234 "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]
3235 }))
3236 .await
3237 .assert_status_ok();
3238 }
3239
3240 let resp = server.get("/.well-known/oauth-authorization-server").await;
3241 resp.assert_status_ok();
3242 let body: serde_json::Value = resp.json();
3243 assert!(body["registration_endpoint"].is_string());
3244 }
3245
3246 #[tokio::test]
3247 async fn test_register_client_cap_of_two_accepts_two_then_rejects() {
3248 let dir = tempfile::tempdir().unwrap();
3251 let config = OAuthConfig::builder(
3252 "https://mcp.example.com".into(),
3253 "test-client-id".into(),
3254 "test-client-secret".into(),
3255 "Test App".into(),
3256 dir.path().join("passkeys.json"),
3257 )
3258 .setup_token("setup-token-123")
3259 .max_registered_clients(Some(2))
3260 .build()
3261 .unwrap();
3262 let server = TestServer::new(build_test_app_with_config(config));
3263
3264 for _ in 0..2 {
3265 let resp = server
3266 .post("/register")
3267 .json(&serde_json::json!({
3268 "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]
3269 }))
3270 .await;
3271 resp.assert_status_ok();
3272 }
3273
3274 let resp = server
3275 .post("/register")
3276 .json(&serde_json::json!({
3277 "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"]
3278 }))
3279 .await;
3280 resp.assert_status(StatusCode::FORBIDDEN);
3281 let body: serde_json::Value = resp.json();
3282 assert_eq!(body["error"], "registration_locked");
3283 }
3284}