turbomcp_auth/oauth2/
validation.rs

1//! OAuth 2.1 Validation Functions
2//!
3//! This module provides validation functions for OAuth 2.1 flows:
4//! - RFC 8707 Resource Indicator validation
5//! - URI format and security validation
6//! - Canonical form validation
7
8use turbomcp_protocol::{Error as McpError, Result as McpResult};
9
10/// RFC 8707 canonical URI validation for Resource Indicators
11///
12/// Validates that a resource URI:
13/// - Uses http or https scheme
14/// - Does not contain fragments
15/// - Has a valid host component
16/// - Uses canonical form (lowercase scheme and host)
17///
18/// # Arguments
19/// * `uri` - The resource URI to validate
20///
21/// # Returns
22/// * `Ok(())` if the URI is valid
23/// * `Err(McpError)` if validation fails
24///
25/// # RFC 8707 Compliance
26/// This function ensures resource URIs are in canonical form as required by RFC 8707.
27/// MCP servers must use canonical URIs to prevent token binding issues.
28pub fn validate_canonical_resource_uri(uri: &str) -> McpResult<()> {
29    use url::Url;
30
31    // Check canonical form BEFORE parsing (URL parser normalizes automatically)
32    // RFC 8707 requires canonical URIs: lowercase scheme and host
33    let scheme_end = uri
34        .find("://")
35        .ok_or_else(|| McpError::validation("Resource URI must have a valid scheme".to_string()))?;
36
37    let scheme = &uri[..scheme_end];
38    if scheme != scheme.to_lowercase() {
39        return Err(McpError::validation(
40            "Resource URI must use canonical form (lowercase scheme and host)".to_string(),
41        ));
42    }
43
44    let parsed =
45        Url::parse(uri).map_err(|e| McpError::validation(format!("Invalid resource URI: {e}")))?;
46
47    // RFC 8707 requirements
48    if parsed.scheme() != "https" && parsed.scheme() != "http" {
49        return Err(McpError::validation(
50            "Resource URI must use http or https scheme".to_string(),
51        ));
52    }
53
54    if parsed.fragment().is_some() {
55        return Err(McpError::validation(
56            "Resource URI must not contain fragment".to_string(),
57        ));
58    }
59
60    // MCP-specific validation for canonical URIs
61    if parsed.host_str().is_none() {
62        return Err(McpError::validation(
63            "Resource URI must include host".to_string(),
64        ));
65    }
66
67    // Extract host once to verify it exists (safe because of validation above)
68    let _host = parsed.host_str().expect("host validated above");
69
70    // Check host is lowercase (canonical form)
71    // We check the original URI since URL parser might normalize
72    let host_start = uri.find("://").expect("scheme checked above") + 3;
73    let host_in_uri = &uri[host_start..];
74    let host_end = host_in_uri
75        .find(['/', ':', '?', '#'])
76        .unwrap_or(host_in_uri.len());
77    let original_host = &host_in_uri[..host_end];
78
79    if original_host != original_host.to_lowercase() {
80        return Err(McpError::validation(
81            "Resource URI must use canonical form (lowercase scheme and host)".to_string(),
82        ));
83    }
84
85    Ok(())
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn test_valid_https_uri() {
94        assert!(validate_canonical_resource_uri("https://example.com/resource").is_ok());
95    }
96
97    #[test]
98    fn test_valid_http_uri() {
99        assert!(validate_canonical_resource_uri("http://example.com/resource").is_ok());
100    }
101
102    #[test]
103    fn test_non_canonical_uppercase_host() {
104        let result = validate_canonical_resource_uri("https://Example.COM/resource");
105        assert!(result.is_err());
106        assert!(result.unwrap_err().to_string().contains("canonical form"));
107    }
108
109    #[test]
110    fn test_non_canonical_uppercase_scheme() {
111        let result = validate_canonical_resource_uri("HTTPS://example.com/resource");
112        assert!(result.is_err());
113        assert!(result.unwrap_err().to_string().contains("canonical form"));
114    }
115
116    #[test]
117    fn test_missing_host() {
118        // file:// scheme is rejected before host check
119        let result = validate_canonical_resource_uri("file:///etc/passwd");
120        assert!(result.is_err());
121        assert!(
122            result
123                .unwrap_err()
124                .to_string()
125                .contains("http or https scheme")
126        );
127
128        // For testing missing host with valid scheme, URL parser doesn't allow empty host with http/https
129        // so we test the host check implicitly through the canonical form tests
130    }
131
132    #[test]
133    fn test_fragment_not_allowed() {
134        let result = validate_canonical_resource_uri("https://example.com/resource#fragment");
135        assert!(result.is_err());
136        assert!(result.unwrap_err().to_string().contains("fragment"));
137    }
138
139    #[test]
140    fn test_invalid_scheme() {
141        let result = validate_canonical_resource_uri("ftp://example.com/resource");
142        assert!(result.is_err());
143        assert!(
144            result
145                .unwrap_err()
146                .to_string()
147                .contains("http or https scheme")
148        );
149    }
150}