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
227/// Check whether an introspection response's `aud` satisfies the configured
228/// expected audience.
229///
230/// When no audience is expected, validation always passes. When an audience is
231/// expected, the response `aud` must contain it (as a string, or as an element
232/// of a string array). If an audience is expected but the response omits `aud`
233/// (or carries an unexpected type), this fails closed rather than accept a
234/// token that may have been issued for a different resource.
235fn audience_matches(expected: Option<&str>, aud: Option<&serde_json::Value>) -> bool {
236 let Some(expected) = expected else {
237 return true; // No expected audience configured; don't reject.
238 };
239 match aud {
240 Some(serde_json::Value::String(s)) => s == expected,
241 Some(serde_json::Value::Array(arr)) => arr
242 .iter()
243 .any(|v| v.as_str().is_some_and(|s| s == expected)),
244 _ => false,
245 }
246}
247
248impl TokenValidator for IntrospectionValidator {
249 async fn validate_token(&self, token: &str) -> Result<TokenClaims, OAuthError> {
250 let resp = self
251 .inner
252 .http_client
253 .post(&self.inner.introspection_endpoint)
254 .basic_auth(&self.inner.client_id, Some(&self.inner.client_secret))
255 .form(&[("token", token)])
256 .send()
257 .await
258 .map_err(|e| OAuthError::InvalidToken {
259 description: format!("introspection request failed: {e}"),
260 })?;
261
262 if !resp.status().is_success() {
263 return Err(OAuthError::InvalidToken {
264 description: format!("introspection endpoint returned {}", resp.status()),
265 });
266 }
267
268 let introspection: IntrospectionResponse =
269 resp.json().await.map_err(|e| OAuthError::InvalidToken {
270 description: format!("invalid introspection response: {e}"),
271 })?;
272
273 if !introspection.active {
274 return Err(OAuthError::InvalidToken {
275 description: "token is not active".to_string(),
276 });
277 }
278
279 // Validate audience if configured
280 if !audience_matches(
281 self.inner.expected_audience.as_deref(),
282 introspection.aud.as_ref(),
283 ) {
284 return Err(OAuthError::InvalidAudience);
285 }
286
287 Ok(TokenClaims {
288 sub: introspection.sub,
289 iss: introspection.iss,
290 aud: None,
291 exp: introspection.exp,
292 scope: introspection.scope,
293 client_id: introspection.client_id,
294 extra: std::collections::HashMap::new(),
295 })
296 }
297}
298
299// ---------------------------------------------------------------------------
300// Fallback Validator: JWT first, then introspection
301// ---------------------------------------------------------------------------
302
303/// Token validator that tries JWT validation first and falls back to introspection.
304///
305/// Useful when the authorization server issues both JWTs and opaque tokens.
306/// JWT validation is preferred (no network call) but introspection handles
307/// opaque tokens that can't be decoded as JWTs.
308#[derive(Clone)]
309pub struct FallbackValidator<J: TokenValidator> {
310 jwt_validator: J,
311 introspection_validator: IntrospectionValidator,
312}
313
314impl<J: TokenValidator> FallbackValidator<J> {
315 /// Create a fallback validator that tries `jwt_validator` first,
316 /// then `introspection_validator` if JWT validation fails.
317 pub fn new(jwt_validator: J, introspection_validator: IntrospectionValidator) -> Self {
318 Self {
319 jwt_validator,
320 introspection_validator,
321 }
322 }
323}
324
325impl<J: TokenValidator> TokenValidator for FallbackValidator<J> {
326 async fn validate_token(&self, token: &str) -> Result<TokenClaims, OAuthError> {
327 // Try JWT first (fast, no network call)
328 match self.jwt_validator.validate_token(token).await {
329 Ok(claims) => Ok(claims),
330 Err(_jwt_err) => {
331 // Fall back to introspection
332 self.introspection_validator.validate_token(token).await
333 }
334 }
335 }
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn test_introspection_validator_creation() {
344 let validator = IntrospectionValidator::new(
345 "https://auth.example.com/oauth/introspect",
346 "client-id",
347 "client-secret",
348 )
349 .expected_audience("mcp-proxy");
350
351 assert_eq!(
352 validator.inner.introspection_endpoint,
353 "https://auth.example.com/oauth/introspect"
354 );
355 assert_eq!(
356 validator.inner.expected_audience.as_deref(),
357 Some("mcp-proxy")
358 );
359 }
360
361 #[test]
362 fn test_audience_missing_when_expected_is_rejected() {
363 // expected_audience set + response has no `aud` -> rejected (fail closed)
364 assert!(!audience_matches(Some("mcp-proxy"), None));
365 // An unexpected `aud` type is likewise rejected.
366 assert!(!audience_matches(
367 Some("mcp-proxy"),
368 Some(&serde_json::Value::Null)
369 ));
370 }
371
372 #[test]
373 fn test_audience_match_when_expected_is_accepted() {
374 // expected_audience set + response `aud` string matches -> accepted
375 assert!(audience_matches(
376 Some("mcp-proxy"),
377 Some(&serde_json::json!("mcp-proxy"))
378 ));
379 // expected_audience set + response `aud` array contains it -> accepted
380 assert!(audience_matches(
381 Some("mcp-proxy"),
382 Some(&serde_json::json!(["other", "mcp-proxy"]))
383 ));
384 // expected_audience set + response `aud` does not match -> rejected
385 assert!(!audience_matches(
386 Some("mcp-proxy"),
387 Some(&serde_json::json!("someone-else"))
388 ));
389 }
390
391 #[test]
392 fn test_audience_not_expected_accepts_missing_aud() {
393 // expected_audience NOT set + response has no `aud` -> accepted (unchanged)
394 assert!(audience_matches(None, None));
395 // No expected audience: any `aud` value is fine.
396 assert!(audience_matches(None, Some(&serde_json::json!("anything"))));
397 }
398
399 #[test]
400 fn test_fallback_validator_creation() {
401 let jwt = IntrospectionValidator::new("https://example.com/introspect", "id", "secret");
402 let introspection =
403 IntrospectionValidator::new("https://example.com/introspect", "id", "secret");
404 let _fallback = FallbackValidator::new(jwt, introspection);
405 }
406}