Skip to main content

mcp_oauth/
lib.rs

1//! # mcp-oauth
2//!
3//! A reusable OAuth 2.1 layer for [MCP](https://modelcontextprotocol.io)
4//! (Model Context Protocol) servers, designed for compatibility with Claude.ai.
5//!
6//! This crate is not a standalone binary — consumers import it and call
7//! [`build_oauth_router_with_stores`] to wrap their [axum](https://docs.rs/axum) `Router`
8//! with a complete OAuth 2.1 implementation.
9//!
10//! ## Features
11//!
12//! - **OAuth 2.1 with PKCE** (S256) — authorization code flow with proof key
13//! - **Dynamic client registration** ([RFC 7591](https://www.rfc-editor.org/rfc/rfc7591))
14//! - **`WebAuthn` / passkey authentication** — passwordless approval via hardware keys or biometrics
15//! - **Token refresh** — long-lived sessions via refresh tokens
16//! - **Per-IP rate limiting** — three tiers (auth, registration, general)
17//! - **Pluggable storage** via [`TokenStore`], [`ClientStore`], [`PasskeyStore`] traits
18//!
19//! ## Quick start
20//!
21//! ```rust,no_run
22//! use axum::Router;
23//! use mcp_oauth::{OAuthConfig, build_oauth_router_with_stores};
24//! use std::path::PathBuf;
25//!
26//! let mcp_routes = Router::new(); // your protected MCP routes
27//!
28//! // Using the builder (recommended):
29//! let config = OAuthConfig::builder(
30//!     "https://my-mcp.example.com".into(),
31//!     "my-client-id".into(),
32//!     "my-client-secret".into(),
33//!     "My MCP Server".into(),
34//!     PathBuf::from("passkeys.json"),
35//! )
36//! .setup_token("initial-setup-token")
37//! .add_redirect_uri("https://myapp.example.com/callback")
38//! .build()
39//! .expect("valid config");
40//!
41//! let (token_store, client_store, passkey_store) =
42//!     mcp_oauth::create_default_stores(&config);
43//! let app = build_oauth_router_with_stores(
44//!     mcp_routes, config, token_store, client_store, passkey_store,
45//! );
46//! // Serve `app` with axum / hyper as usual.
47//! ```
48
49pub 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
84// L2: use unwrap_or_default to avoid panic on pre-epoch system time
85fn now_epoch() -> u64 {
86    SystemTime::now()
87        .duration_since(UNIX_EPOCH)
88        .unwrap_or_default()
89        .as_secs()
90}
91
92// H1: Constant-time comparison for secrets to prevent timing side-channels
93fn 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// L1: Generate 256-bit cryptographically random tokens (base64url-encoded)
103#[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// ---------------------------------------------------------------------------
116// Public config
117// ---------------------------------------------------------------------------
118
119/// Per-IP rate limiting configuration (requests per minute).
120#[derive(Debug, Clone)]
121pub struct RateLimitConfig {
122    /// Rate limit for auth-critical endpoints (`/token`, `/register`, `/passkey/*`).
123    /// Default: 10 req/min.
124    pub strict: u32,
125    /// Rate limit for public endpoints (`/.well-known/*`, `/authorize`, `/health`).
126    /// Default: 30 req/min.
127    pub moderate: u32,
128    /// Rate limit for protected (Bearer-authed) routes.
129    /// Default: 60 req/min.
130    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/// Capacity limits for in-memory transient state and persistent stores.
144#[derive(Debug, Clone)]
145pub struct CapacityConfig {
146    /// Max pending passkey registration sessions. Default: 10000.
147    pub max_registration_states: usize,
148    /// Max pending passkey authentication sessions. Default: 10000.
149    pub max_authentication_states: usize,
150    /// Max simultaneously stored access tokens. Default: 10000.
151    pub max_access_tokens: usize,
152    /// Max simultaneously stored refresh tokens. Default: 10000.
153    pub max_refresh_tokens: usize,
154    /// Max pending (unconsumed) authorization codes. Default: 10000.
155    pub max_auth_codes: usize,
156    /// Max dynamically registered OAuth clients.
157    ///
158    /// - `Some(n)` caps the store at `n` clients (default `Some(1)`: preserves
159    ///   the historical single-client registration lock).
160    /// - `None` allows unlimited dynamic client registrations.
161    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/// Errors that can occur when building an [`OAuthConfig`] via the builder.
178#[derive(Debug, thiserror::Error)]
179#[non_exhaustive]
180pub enum OAuthConfigError {
181    /// `client_id` must not be empty.
182    #[error("client_id must not be empty")]
183    EmptyClientId,
184    /// `client_secret` must not be empty.
185    #[error("client_secret must not be empty")]
186    EmptyClientSecret,
187    /// `passkey_store_path` must not contain `..` components.
188    #[error("passkey_store_path must not contain '..' components")]
189    PathTraversal,
190    /// Rate limit values must be non-zero.
191    #[error("rate limit values must be non-zero")]
192    ZeroRateLimit,
193    /// At least one scope is required.
194    #[error("scopes must not be empty")]
195    EmptyScopes,
196    /// A capacity limit was set to zero. Use `None` on `max_registered_clients`
197    /// for "unlimited"; all other capacity fields must be at least 1.
198    #[error("capacity limit must be at least 1 (use max_registered_clients: None for unlimited)")]
199    ZeroCapacity,
200}
201
202/// Returns the default allowed redirect URIs (Claude.ai callbacks).
203#[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    /// The public-facing URL of this server (e.g. `<https://my-mcp.fly.dev>`).
214    pub server_url: String,
215    /// Pre-registered OAuth client ID.
216    pub client_id: String,
217    /// Pre-registered OAuth client secret.
218    pub client_secret: String,
219    /// Human-readable app name shown on pages.
220    pub app_name: String,
221    /// Where to persist registered passkeys (JSON file).
222    pub passkey_store_path: PathBuf,
223    /// One-time token for first passkey registration (when no passkeys exist yet).
224    pub setup_token: Option<String>,
225    /// Access token lifetime in seconds. Default: 86400 (24 hours).
226    pub token_lifetime_secs: u64,
227    /// Authorization code lifetime in seconds. Default: 300 (5 minutes).
228    pub code_lifetime_secs: u64,
229    /// Redirect URIs that are always accepted (beyond per-client registered URIs).
230    /// Defaults to the Claude.ai callback URLs.
231    pub allowed_redirect_uris: Vec<String>,
232    /// Per-IP rate limiting tiers.
233    pub rate_limits: RateLimitConfig,
234    /// In-memory capacity limits for transient state.
235    pub capacity: CapacityConfig,
236    /// OAuth scopes supported and returned in token responses.
237    /// Defaults to `["mcp:tools"]`.
238    pub scopes: Vec<String>,
239}
240
241impl OAuthConfig {
242    /// Create a new `OAuthConfig` with default token lifetimes.
243    ///
244    /// # Panics
245    ///
246    /// Panics if `client_id` or `client_secret` is empty.
247    #[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        // L5: Validate non-empty credentials to prevent empty-string bypass
257        assert!(!client_id.is_empty(), "client_id must not be empty");
258        assert!(!client_secret.is_empty(), "client_secret must not be empty");
259        // Defense-in-depth: reject paths with parent-directory traversal components
260        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    /// Create a builder for `OAuthConfig` with required parameters.
284    ///
285    /// # Example
286    ///
287    /// ```rust
288    /// use mcp_oauth::OAuthConfig;
289    /// use std::path::PathBuf;
290    ///
291    /// let config = OAuthConfig::builder(
292    ///     "https://my-mcp.example.com".into(),
293    ///     "my-client-id".into(),
294    ///     "my-client-secret".into(),
295    ///     "My MCP Server".into(),
296    ///     PathBuf::from("passkeys.json"),
297    /// )
298    /// .setup_token("initial-setup-token")
299    /// .token_lifetime_secs(3600)
300    /// .add_redirect_uri("https://myapp.example.com/callback")
301    /// .build()
302    /// .expect("valid config");
303    /// ```
304    #[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
329/// Builder for [`OAuthConfig`].
330///
331/// Created via [`OAuthConfig::builder`]. Call [`.build()`](OAuthConfigBuilder::build)
332/// to validate and produce the final config.
333pub 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    /// Set the one-time setup token for first passkey registration.
350    #[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    /// Set access token lifetime in seconds (default: 86400 = 24 hours).
357    #[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    /// Set authorization code lifetime in seconds (default: 300 = 5 minutes).
364    #[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    /// Replace all allowed redirect URIs (overrides the default Claude.ai URIs).
371    #[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    /// Add an additional allowed redirect URI (appends to defaults).
378    #[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    /// Set per-IP rate limiting configuration.
385    #[must_use]
386    pub const fn rate_limits(mut self, config: RateLimitConfig) -> Self {
387        self.rate_limits = config;
388        self
389    }
390
391    /// Set the full capacity configuration.
392    #[must_use]
393    pub const fn capacity(mut self, config: CapacityConfig) -> Self {
394        self.capacity = config;
395        self
396    }
397
398    /// Set the maximum number of simultaneously stored access tokens.
399    #[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    /// Set the maximum number of simultaneously stored refresh tokens.
406    #[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    /// Set the maximum number of pending authorization codes.
413    #[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    /// Set the cap on dynamically registered clients.
420    ///
421    /// Pass `None` for unlimited registrations, or `Some(n)` to cap the store
422    /// at `n` clients. The default is `Some(1)`, which preserves the
423    /// historical single-client registration lock.
424    #[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    /// Replace all supported OAuth scopes (overrides the default `["mcp:tools"]`).
431    #[must_use]
432    pub fn scopes(mut self, scopes: Vec<String>) -> Self {
433        self.scopes = scopes;
434        self
435    }
436
437    /// Add an additional OAuth scope (appends to defaults).
438    #[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    /// Validate and build the [`OAuthConfig`].
445    ///
446    /// # Errors
447    ///
448    /// Returns [`OAuthConfigError`] if validation fails.
449    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// ---------------------------------------------------------------------------
500// Internal server state
501// ---------------------------------------------------------------------------
502
503#[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
516// H2: Capacity limits now configurable via OAuthConfig.capacity
517use 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    // Passkey / WebAuthn state
525    webauthn: Webauthn,
526    // H2: Timestamps added for TTL-based cleanup
527    registration_states: Mutex<HashMap<String, (PasskeyRegistration, u64)>>,
528    authentication_states:
529        Mutex<HashMap<String, (PasskeyAuthentication, PendingAuthApproval, u64)>>,
530    // Session cookie for auto-approving /authorize after first passkey auth
531    auth_session_token: Mutex<Option<(String, u64)>>, // (token, created_at_epoch)
532}
533
534// Allowed redirect URIs now configurable via OAuthConfig.allowed_redirect_uris
535
536type AppState<T, C, P> = Arc<OAuthServer<T, C, P>>;
537
538// L3: Return Result instead of silently falling back to "localhost"
539fn 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    // H1: Constant-time secret comparison to prevent timing side-channels
548    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
610// ---------------------------------------------------------------------------
611// Rate limiting
612// ---------------------------------------------------------------------------
613
614type 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
625/// Extract the client IP from the request.
626/// Prefers `CF-Connecting-IP` (set by Cloudflare Tunnel), falls back to
627/// `X-Forwarded-For`, then peer socket address, then a static "unknown" key.
628fn 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    // Fall back to peer socket address (available when server uses
645    // into_make_service_with_connect_info::<SocketAddr>())
646    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
670// ---------------------------------------------------------------------------
671// Public API: create_default_stores
672// ---------------------------------------------------------------------------
673
674/// Create the default JSON-file-backed stores from an [`OAuthConfig`].
675///
676/// Returns `(token_store, client_store, passkey_store)` suitable for passing
677/// to [`build_oauth_router_with_stores`].
678pub 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// ---------------------------------------------------------------------------
704// Public API: build_oauth_router (deprecated) and build_oauth_router_with_stores
705// ---------------------------------------------------------------------------
706
707/// Wraps `protected_router` with OAuth 2.1 endpoints and Bearer-token middleware.
708///
709/// # Deprecated
710///
711/// Use [`build_oauth_router_with_stores`] with explicit store implementations instead.
712/// This function creates default JSON-file-backed stores from the config.
713#[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/// Wraps `protected_router` with OAuth 2.1 endpoints and Bearer-token middleware.
729///
730/// # Rate Limiting
731///
732/// Three tiers of per-IP rate limiting (keyed by `CF-Connecting-IP` header):
733/// - **Strict (10 req/min):** `/token`, `/register`, `/passkey/*` — auth brute-force protection
734/// - **Moderate (30 req/min):** `/authorize`, `/.well-known/*`, `/health` — OAuth flow, metadata
735/// - **Lenient (60 req/min):** `/mcp` (protected routes) — already behind Bearer auth
736///
737/// # Panics
738///
739/// Panics if `config.server_url` is not a valid URL or has no host component,
740/// or if the `WebAuthn` builder fails (invalid RP configuration).
741#[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    // Auth routes: strict rate limiting (10 req/min per IP)
790    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    // Other public routes: moderate rate limiting (30 req/min per IP)
811    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    // M5: Security headers on all public responses
829    let public_routes = auth_routes
830        .merge(other_public)
831        .layer(middleware::from_fn(security_headers_middleware));
832
833    // Protected routes: lenient rate limiting (60 req/min per IP), then auth
834    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
849// Request logging middleware — logs ALL incoming requests
850async 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// M5: Security headers middleware
867#[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
892// ---------------------------------------------------------------------------
893// Well-known metadata endpoints
894// ---------------------------------------------------------------------------
895
896async 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    // Advertise the registration endpoint whenever it would accept a new
923    // registration: either the client cap is unlimited (`None`) or the store
924    // is still under the configured cap. This keeps RFC 7591 discovery
925    // consistent with the actual acceptance behaviour of `POST /register`.
926    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// ---------------------------------------------------------------------------
938// Dynamic Client Registration (RFC 7591)
939// ---------------------------------------------------------------------------
940
941#[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    // L1: Use cryptographically strong tokens instead of UUID v4
990    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    // Atomically check-and-insert to prevent TOCTOU race between
1005    // concurrent registration requests.
1006    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// ---------------------------------------------------------------------------
1043// Authorization endpoint (now passkey-driven)
1044// ---------------------------------------------------------------------------
1045
1046#[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    // Check for valid auth session cookie — auto-approve without passkey
1139    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        // C2: Build redirect URL safely using url::Url to properly encode parameters
1182        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) = &params.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// ---------------------------------------------------------------------------
1218// Token endpoint
1219// ---------------------------------------------------------------------------
1220
1221#[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, &params).await,
1278        "refresh_token" => handle_refresh_token(&store, client_id, &params).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    // M4: Validate PKCE code_verifier length per RFC 7636 (43-128 characters)
1299    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    // L1: Use cryptographically strong tokens
1368    let access_token = generate_token();
1369    let refresh_token = generate_token();
1370
1371    // Store access token (capacity checks happen inside the store)
1372    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    // Peek at the refresh token first (non-destructive) to validate client_id
1414    // before consuming it. This prevents an attacker who knows a token value
1415    // but not the correct client_id from destroying the victim's refresh token.
1416    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    // Now consume (remove) the validated refresh token
1452    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    // M1: Only revoke the specific access token associated with the consumed refresh token,
1464    // not all tokens for the client
1465    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    // L1: Use cryptographically strong tokens
1472    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
1509// ---------------------------------------------------------------------------
1510// Auth middleware for protected routes
1511// ---------------------------------------------------------------------------
1512
1513async 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 the inner service returned 401 but our auth was valid,
1541            // it's a session-not-found error from rmcp (e.g. after server restart).
1542            // Convert to 404 so MCP clients create a new session instead of
1543            // endlessly refreshing their token.
1544            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
1572/// Map a [`StoreError`] to an HTTP error response.
1573fn 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// ---------------------------------------------------------------------------
1603// Passkey registration endpoints
1604// ---------------------------------------------------------------------------
1605
1606#[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        // Passkey registration permanently locked after first passkey.
1654        // To reset: delete passkeys.json and restart the server.
1655        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    // First registration: require setup token
1667    // H1: Constant-time comparison for setup token
1668    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]; // single-user system
1681    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        // M6: Log full error, return generic message to client
1702        .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    // H2: Cleanup expired entries and enforce capacity on registration_states
1716    {
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, &reg_state)
1764        // M6: Log full error, return generic message to client
1765        .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    // Atomically check-and-insert to prevent TOCTOU race where multiple
1777    // registrations are started concurrently before the first one completes.
1778    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    // Invalidate all other pending registration sessions to prevent
1806    // concurrent registrations started before lockdown from completing.
1807    store.registration_states.lock().await.clear();
1808
1809    Ok(Json(serde_json::json!({ "ok": true })))
1810}
1811
1812// ---------------------------------------------------------------------------
1813// Passkey authentication endpoints
1814// ---------------------------------------------------------------------------
1815
1816#[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    // Validate OAuth params
1847    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        // M6: Log full error, return generic message to client
1901        .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    // H2: Cleanup expired entries and enforce capacity on authentication_states
1922    {
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        // M6: Log full error, return generic message to client
1970        .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    // Update credential counter for replay protection
1982    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    // L1: Use cryptographically strong token for auth code
1987    let code = generate_token();
1988    let now = now_epoch();
1989
1990    // Store auth code (capacity checks happen inside the store)
1991    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    // C2: Build redirect URL safely using url::Url to properly encode parameters
2015    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    // Create auth session cookie so subsequent /authorize auto-approves
2033    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
2048// ---------------------------------------------------------------------------
2049// HTML templates (Askama — compiled at build time from templates/ directory)
2050// ---------------------------------------------------------------------------
2051
2052const 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    // C1: Serialize OAuth params as JSON and embed in a non-executable <script> data block
2111    // to prevent XSS via malicious parameter values.
2112    // In <script> tags, HTML entities are NOT decoded, so we use |safe in the template.
2113    // Only escape </ to prevent </script> injection.
2114    #[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: &params_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// ---------------------------------------------------------------------------
2166// Tests
2167// ---------------------------------------------------------------------------
2168
2169#[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    // -- Unit tests for helper functions --
2201
2202    #[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        // 32 bytes -> 43 base64url chars (no padding)
2226        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        // Should be after 2024-01-01
2267        assert!(now > 1_704_067_200);
2268    }
2269
2270    // -- OAuthConfig tests --
2271
2272    #[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    // -- Builder tests --
2327
2328    #[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        // Each variant's Display output must contain a recognisable substring
2590        // so error messages remain useful in logs/telemetry.
2591        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    // -- Integration tests --
2620
2621    #[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        // Registration should be advertised when no clients registered
2665        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        // Should include WWW-Authenticate header
2676        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        // Missing response_type
2742        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        // No passkeys registered, so should show setup page
2796        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        // First registration succeeds
2880        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        // Second registration is locked
2889        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    // -- Persistence tests --
2974
2975    #[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    // -- Template rendering tests --
3016
3017    #[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        // Should render setup template when no passkeys
3028        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        // Should embed OAuth params as JSON
3036        assert!(html.contains("cid"));
3037    }
3038
3039    #[test]
3040    fn test_authorize_page_xss_prevention() {
3041        // Verify that </script> in params doesn't break out of the JSON block
3042        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    // -- Builder integration tests --
3064
3065    #[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        // Under a Some(2) cap with one client already registered, discovery
3143        // via OAuth server metadata must still advertise /register so that
3144        // RFC 7591 clients can bootstrap the second registration.
3145        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        // Once the registered-client count hits the cap, the endpoint is no
3179        // longer discoverable via metadata (mirrors the existing single-client
3180        // lock behaviour but driven by the configurable cap).
3181        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        // With max_registered_clients = None (unlimited), the endpoint stays
3215        // advertised forever.
3216        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        // Configure a 2-client cap and verify the first two registrations
3249        // succeed while the third is rejected with `registration_locked`.
3250        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}