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}