Skip to main content

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}