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
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}