mcp_proxy/introspection.rs
1//! OAuth 2.1 token introspection and authorization server discovery.
2//!
3//! This module implements two complementary OAuth standards for token
4//! validation in environments where local JWT verification alone is
5//! insufficient:
6//!
7//! - **RFC 8414** -- Authorization server metadata discovery. The proxy
8//! fetches the issuer's `.well-known/oauth-authorization-server` (or
9//! `.well-known/openid-configuration` as a fallback) to auto-discover
10//! the JWKS URI, introspection endpoint, and other server capabilities.
11//! See [`discover_auth_server`] and [`AuthServerMetadata`].
12//!
13//! - **RFC 7662** -- Token introspection. The proxy calls the authorization
14//! server's introspection endpoint with client credentials to validate
15//! opaque (non-JWT) tokens at request time. See [`IntrospectionValidator`].
16//!
17//! # Validation strategies
18//!
19//! The [`FallbackValidator`] combines both approaches: it attempts fast,
20//! local JWT validation first (no network call), and falls back to
21//! introspection only when JWT decoding fails. This is the recommended
22//! strategy when the authorization server issues both JWT and opaque tokens.
23//!
24//! | Strategy | Type | Network per request | Use when |
25//! |---|---|---|---|
26//! | JWT only | [`TokenValidator`] | No | All tokens are JWTs |
27//! | Introspection only | [`IntrospectionValidator`] | Yes | Opaque tokens, real-time revocation |
28//! | Both (fallback) | [`FallbackValidator`] | Sometimes | Mixed token types |
29//!
30//! The strategy is selected via the `token_validation` field in the auth
31//! config: `"jwt"` (default), `"introspection"`, or `"both"`.
32//!
33//! # Configuration example
34//!
35//! ```toml
36//! [auth]
37//! type = "oauth"
38//! issuer = "https://auth.example.com"
39//! audience = "mcp-proxy"
40//! client_id = "mcp-proxy-client"
41//! client_secret = "${OAUTH_CLIENT_SECRET}"
42//! token_validation = "both"
43//!
44//! # Optional: override auto-discovered endpoints
45//! # jwks_uri = "https://auth.example.com/custom/jwks"
46//! # introspection_endpoint = "https://auth.example.com/custom/introspect"
47//! ```
48//!
49//! When `token_validation` is `"introspection"` or `"both"`, `client_id` and
50//! `client_secret` are required (the proxy authenticates to the introspection
51//! endpoint using HTTP Basic auth with these credentials).
52//!
53//! # Discovery flow
54//!
55//! [`discover_auth_server`] performs metadata discovery in two steps:
56//!
57//! 1. Fetch `{issuer}/.well-known/oauth-authorization-server` (RFC 8414).
58//! 2. If that fails, fall back to `{issuer}/.well-known/openid-configuration`
59//! (OpenID Connect Discovery).
60//!
61//! The returned [`AuthServerMetadata`] provides the JWKS URI and introspection
62//! endpoint used to construct validators at proxy startup.
63
64use std::sync::Arc;
65
66use tower_mcp::oauth::OAuthError;
67use tower_mcp::oauth::token::{TokenClaims, TokenValidator};
68
69// ---------------------------------------------------------------------------
70// RFC 8414: Authorization Server Metadata
71// ---------------------------------------------------------------------------
72
73/// Discovered authorization server metadata (RFC 8414).
74#[derive(Debug, Clone, serde::Deserialize)]
75pub struct AuthServerMetadata {
76 /// The authorization server's issuer identifier.
77 pub issuer: String,
78 /// URL of the authorization server's JWK Set document.
79 #[serde(default)]
80 pub jwks_uri: Option<String>,
81 /// URL of the token introspection endpoint (RFC 7662).
82 #[serde(default)]
83 pub introspection_endpoint: Option<String>,
84 /// URL of the token endpoint.
85 #[serde(default)]
86 pub token_endpoint: Option<String>,
87 /// URL of the authorization endpoint.
88 #[serde(default)]
89 pub authorization_endpoint: Option<String>,
90 /// Supported scopes.
91 #[serde(default)]
92 pub scopes_supported: Vec<String>,
93 /// Supported response types.
94 #[serde(default)]
95 pub response_types_supported: Vec<String>,
96 /// Supported grant types.
97 #[serde(default)]
98 pub grant_types_supported: Vec<String>,
99 /// Supported token endpoint auth methods.
100 #[serde(default)]
101 pub token_endpoint_auth_methods_supported: Vec<String>,
102}
103
104/// Discover authorization server metadata from an issuer URL.
105///
106/// Fetches `{issuer}/.well-known/oauth-authorization-server` per RFC 8414.
107/// Falls back to `{issuer}/.well-known/openid-configuration` for OIDC providers.
108pub async fn discover_auth_server(issuer: &str) -> anyhow::Result<AuthServerMetadata> {
109 let client = reqwest::Client::new();
110 let issuer = issuer.trim_end_matches('/');
111
112 // Try RFC 8414 first
113 let rfc8414_url = format!("{issuer}/.well-known/oauth-authorization-server");
114 if let Ok(resp) = client.get(&rfc8414_url).send().await
115 && resp.status().is_success()
116 && let Ok(metadata) = resp.json::<AuthServerMetadata>().await
117 {
118 tracing::info!(
119 issuer = %metadata.issuer,
120 jwks_uri = ?metadata.jwks_uri,
121 introspection = ?metadata.introspection_endpoint,
122 "Discovered auth server metadata (RFC 8414)"
123 );
124 return Ok(metadata);
125 }
126
127 // Fall back to OpenID Connect discovery
128 let oidc_url = format!("{issuer}/.well-known/openid-configuration");
129 let resp = client
130 .get(&oidc_url)
131 .send()
132 .await
133 .map_err(|e| anyhow::anyhow!("failed to discover auth server at {oidc_url}: {e}"))?;
134
135 if !resp.status().is_success() {
136 anyhow::bail!(
137 "auth server discovery failed: {} returned {}",
138 oidc_url,
139 resp.status()
140 );
141 }
142
143 let metadata = resp
144 .json::<AuthServerMetadata>()
145 .await
146 .map_err(|e| anyhow::anyhow!("failed to parse auth server metadata: {e}"))?;
147
148 tracing::info!(
149 issuer = %metadata.issuer,
150 jwks_uri = ?metadata.jwks_uri,
151 introspection = ?metadata.introspection_endpoint,
152 "Discovered auth server metadata (OIDC)"
153 );
154
155 Ok(metadata)
156}
157
158// ---------------------------------------------------------------------------
159// RFC 7662: Token Introspection Validator
160// ---------------------------------------------------------------------------
161
162/// Token validator using RFC 7662 token introspection.
163///
164/// Calls the authorization server's introspection endpoint to validate
165/// opaque (non-JWT) tokens. Requires OAuth client credentials.
166#[derive(Clone)]
167pub struct IntrospectionValidator {
168 inner: Arc<IntrospectionState>,
169}
170
171struct IntrospectionState {
172 introspection_endpoint: String,
173 client_id: String,
174 client_secret: String,
175 expected_audience: Option<String>,
176 http_client: reqwest::Client,
177}
178
179/// RFC 7662 introspection response.
180#[derive(Debug, serde::Deserialize)]
181struct IntrospectionResponse {
182 /// Whether the token is active.
183 active: bool,
184 /// Token subject.
185 #[serde(default)]
186 sub: Option<String>,
187 /// Token issuer.
188 #[serde(default)]
189 iss: Option<String>,
190 /// Token audience.
191 #[serde(default)]
192 aud: Option<serde_json::Value>,
193 /// Token expiration.
194 #[serde(default)]
195 exp: Option<u64>,
196 /// Space-delimited scopes.
197 #[serde(default)]
198 scope: Option<String>,
199 /// Client ID.
200 #[serde(default)]
201 client_id: Option<String>,
202}
203
204impl IntrospectionValidator {
205 /// Create a new introspection validator.
206 pub fn new(introspection_endpoint: &str, client_id: &str, client_secret: &str) -> Self {
207 Self {
208 inner: Arc::new(IntrospectionState {
209 introspection_endpoint: introspection_endpoint.to_string(),
210 client_id: client_id.to_string(),
211 client_secret: client_secret.to_string(),
212 expected_audience: None,
213 http_client: reqwest::Client::new(),
214 }),
215 }
216 }
217
218 /// Set the expected audience for validation.
219 pub fn expected_audience(mut self, audience: &str) -> Self {
220 Arc::get_mut(&mut self.inner)
221 .expect("no other references")
222 .expected_audience = Some(audience.to_string());
223 self
224 }
225}
226
227impl TokenValidator for IntrospectionValidator {
228 async fn validate_token(&self, token: &str) -> Result<TokenClaims, OAuthError> {
229 let resp = self
230 .inner
231 .http_client
232 .post(&self.inner.introspection_endpoint)
233 .basic_auth(&self.inner.client_id, Some(&self.inner.client_secret))
234 .form(&[("token", token)])
235 .send()
236 .await
237 .map_err(|e| OAuthError::InvalidToken {
238 description: format!("introspection request failed: {e}"),
239 })?;
240
241 if !resp.status().is_success() {
242 return Err(OAuthError::InvalidToken {
243 description: format!("introspection endpoint returned {}", resp.status()),
244 });
245 }
246
247 let introspection: IntrospectionResponse =
248 resp.json().await.map_err(|e| OAuthError::InvalidToken {
249 description: format!("invalid introspection response: {e}"),
250 })?;
251
252 if !introspection.active {
253 return Err(OAuthError::InvalidToken {
254 description: "token is not active".to_string(),
255 });
256 }
257
258 // Validate audience if configured
259 if let Some(expected_aud) = &self.inner.expected_audience {
260 let aud_matches = match &introspection.aud {
261 Some(serde_json::Value::String(s)) => s == expected_aud,
262 Some(serde_json::Value::Array(arr)) => arr
263 .iter()
264 .any(|v| v.as_str().is_some_and(|s| s == expected_aud)),
265 _ => true, // No audience in response; don't reject
266 };
267 if !aud_matches {
268 return Err(OAuthError::InvalidAudience);
269 }
270 }
271
272 Ok(TokenClaims {
273 sub: introspection.sub,
274 iss: introspection.iss,
275 aud: None,
276 exp: introspection.exp,
277 scope: introspection.scope,
278 client_id: introspection.client_id,
279 extra: std::collections::HashMap::new(),
280 })
281 }
282}
283
284// ---------------------------------------------------------------------------
285// Fallback Validator: JWT first, then introspection
286// ---------------------------------------------------------------------------
287
288/// Token validator that tries JWT validation first and falls back to introspection.
289///
290/// Useful when the authorization server issues both JWTs and opaque tokens.
291/// JWT validation is preferred (no network call) but introspection handles
292/// opaque tokens that can't be decoded as JWTs.
293#[derive(Clone)]
294pub struct FallbackValidator<J: TokenValidator> {
295 jwt_validator: J,
296 introspection_validator: IntrospectionValidator,
297}
298
299impl<J: TokenValidator> FallbackValidator<J> {
300 /// Create a fallback validator that tries `jwt_validator` first,
301 /// then `introspection_validator` if JWT validation fails.
302 pub fn new(jwt_validator: J, introspection_validator: IntrospectionValidator) -> Self {
303 Self {
304 jwt_validator,
305 introspection_validator,
306 }
307 }
308}
309
310impl<J: TokenValidator> TokenValidator for FallbackValidator<J> {
311 async fn validate_token(&self, token: &str) -> Result<TokenClaims, OAuthError> {
312 // Try JWT first (fast, no network call)
313 match self.jwt_validator.validate_token(token).await {
314 Ok(claims) => Ok(claims),
315 Err(_jwt_err) => {
316 // Fall back to introspection
317 self.introspection_validator.validate_token(token).await
318 }
319 }
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 #[test]
328 fn test_introspection_validator_creation() {
329 let validator = IntrospectionValidator::new(
330 "https://auth.example.com/oauth/introspect",
331 "client-id",
332 "client-secret",
333 )
334 .expected_audience("mcp-proxy");
335
336 assert_eq!(
337 validator.inner.introspection_endpoint,
338 "https://auth.example.com/oauth/introspect"
339 );
340 assert_eq!(
341 validator.inner.expected_audience.as_deref(),
342 Some("mcp-proxy")
343 );
344 }
345
346 #[test]
347 fn test_fallback_validator_creation() {
348 let jwt = IntrospectionValidator::new("https://example.com/introspect", "id", "secret");
349 let introspection =
350 IntrospectionValidator::new("https://example.com/introspect", "id", "secret");
351 let _fallback = FallbackValidator::new(jwt, introspection);
352 }
353}