Skip to main content

turul_mcp_oauth/
lib.rs

1//! OAuth 2.1 Resource Server support for Turul MCP framework
2//!
3//! This crate provides OAuth 2.1 Bearer token validation for MCP servers
4//! acting as Resource Servers (RS). It does NOT implement an Authorization
5//! Server — tokens are validated against an external AS via JWKS.
6//!
7//! # Authorization-role posture (MCP 2026-07-28)
8//!
9//! The MCP authorization spec assigns requirements per OAuth role. This
10//! crate implements the **resource-server role only**:
11//!
12//! - RFC 9728 Protected Resource Metadata (`/.well-known/oauth-protected-resource`)
13//!   — the RS MUST — via [`ProtectedResourceMetadata`] / [`WellKnownOAuthHandler`].
14//! - OAuth 2.1 §5.2 access-token validation with RFC 8707 audience binding
15//!   — via [`JwtValidator`] / [`OAuthResourceMiddleware`].
16//!
17//! **Client ID Metadata Documents** (CIMD,
18//! draft-ietf-oauth-client-id-metadata-document-00) are a SHOULD for
19//! *authorization servers and MCP clients* — client registration never
20//! involves the resource server, so no CIMD surface belongs in this crate.
21//! Applications embedding an AS or building a full MCP OAuth client implement
22//! CIMD there.
23//!
24//! **Dynamic Client Registration** (RFC 7591) is deprecated upstream (MAY,
25//! retained for AS back-compat; not removed — earliest removal 2027-07-28)
26//! and is likewise an AS/client concern. This crate has never implemented a
27//! DCR surface and does not add one.
28//!
29//! # Architecture
30//!
31//! - **`OAuthResourceMiddleware`** — Pre-session middleware that validates
32//!   Bearer tokens and injects claims into request extensions
33//! - **`JwtValidator`** — JWT validation with JWKS caching and kid-miss refresh.
34//!   Owned by [`turul_jwt_validator`] and re-exported here
35//! - **`hardened_validator`** — builds a [`JwtValidator`] with this crate's key
36//!   max-age, stale window and fetch retry, and rejects a non-loopback
37//!   plaintext `jwks_uri`. The bare constructor leaves all of those disabled
38//! - **`ProtectedResourceMetadata`** — RFC 9728 metadata document
39//! - **`WellKnownOAuthHandler`** — Route handler for `/.well-known/oauth-protected-resource`
40//!
41//! # Usage
42//!
43//! ```rust,no_run
44//! use std::sync::Arc;
45//! use turul_mcp_oauth::{
46//!     JwtValidator, OAuthResourceMiddleware, ProtectedResourceMetadata,
47//!     WellKnownOAuthHandler, oauth_resource_server,
48//! };
49//!
50//! let metadata = ProtectedResourceMetadata::new(
51//!     "https://example.com/mcp",
52//!     vec!["https://auth.example.com".to_string()],
53//! ).unwrap();
54//!
55//! let (auth_middleware, routes) = oauth_resource_server(
56//!     metadata,
57//!     "https://auth.example.com/.well-known/jwks.json",
58//! ).unwrap();
59//!
60//! // Register with McpServer::builder()
61//! //   let mut builder = McpServer::builder().middleware(auth_middleware);
62//! //   for (path, handler) in routes {
63//! //       builder = builder.route(&path, handler);
64//! //   }
65//! ```
66
67pub mod error;
68pub mod jwt;
69pub mod metadata;
70pub mod middleware;
71pub mod well_known;
72
73pub use error::OAuthError;
74pub use jwt::{
75    Algorithm, JwksFetchErrorKind, JwtValidationError, JwtValidator, TokenClaims,
76    hardened_validator,
77};
78pub use metadata::ProtectedResourceMetadata;
79pub use middleware::OAuthResourceMiddleware;
80pub use well_known::WellKnownOAuthHandler;
81
82use std::sync::Arc;
83
84/// A route entry: `(path, handler)` pair for registration with the server builder.
85pub type RouteEntry = (String, Arc<dyn turul_http_mcp_server::routes::RouteHandler>);
86
87/// Convenience function to create both the OAuth middleware and well-known route handlers
88///
89/// Requires exactly one authorization server in the metadata. For multi-AS deployments,
90/// build the validator with [`hardened_validator`] and wire `OAuthResourceMiddleware`
91/// manually — not `JwtValidator::new`, which applies no hardening.
92///
93/// Returns `Ok((middleware, routes))` where `routes` contains path/handler pairs for all
94/// RFC 9728 metadata endpoints. For resources with a path component (e.g.,
95/// `https://example.com/mcp`), this registers both root-form and path-form endpoints:
96///
97/// - `/.well-known/oauth-protected-resource` (root form, always)
98/// - `/.well-known/oauth-protected-resource/mcp` (path form, when resource has a path)
99///
100/// Register all routes with the server builder:
101/// ```rust,ignore
102/// let (middleware, routes) = oauth_resource_server(metadata, jwks_uri)?;
103/// let mut builder = McpServer::builder().middleware(middleware);
104/// for (path, handler) in routes {
105///     builder = builder.route(&path, handler);
106/// }
107/// ```
108pub fn oauth_resource_server(
109    metadata: ProtectedResourceMetadata,
110    jwks_uri: &str,
111) -> Result<(Arc<OAuthResourceMiddleware>, Vec<RouteEntry>), OAuthError> {
112    if metadata.authorization_servers.len() != 1 {
113        return Err(OAuthError::InvalidConfiguration(format!(
114            "oauth_resource_server requires exactly one authorization server, got {}; \
115             for multi-AS deployments, build one with hardened_validator and wire \
116             OAuthResourceMiddleware manually",
117            metadata.authorization_servers.len()
118        )));
119    }
120    let audience = &metadata.resource;
121    let issuer = &metadata.authorization_servers[0];
122    let validator = Arc::new(jwt::hardened_validator(jwks_uri, audience)?.with_issuer(issuer));
123    let middleware = Arc::new(OAuthResourceMiddleware::new(validator, metadata.clone()));
124    let handler: Arc<dyn turul_http_mcp_server::routes::RouteHandler> =
125        Arc::new(WellKnownOAuthHandler::new(&metadata));
126
127    // Register both root-form and path-form endpoints per RFC 9728 §3
128    let routes: Vec<RouteEntry> = metadata
129        .well_known_paths()
130        .into_iter()
131        .map(|path| (path, handler.clone()))
132        .collect();
133
134    Ok((middleware, routes))
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_single_as_ok() {
143        let metadata = ProtectedResourceMetadata::new(
144            "https://example.com/mcp",
145            vec!["https://auth.example.com".to_string()],
146        )
147        .unwrap();
148        assert!(
149            oauth_resource_server(metadata, "https://auth.example.com/.well-known/jwks.json")
150                .is_ok()
151        );
152    }
153
154    #[test]
155    fn test_multiple_as_rejected() {
156        let metadata = ProtectedResourceMetadata::new(
157            "https://example.com/mcp",
158            vec![
159                "https://a1.example.com".to_string(),
160                "https://a2.example.com".to_string(),
161            ],
162        )
163        .unwrap();
164        assert!(
165            oauth_resource_server(metadata, "https://auth.example.com/.well-known/jwks.json")
166                .is_err()
167        );
168    }
169}