Skip to main content

tower_mcp/oauth/
error.rs

1//! OAuth 2.1 error types and WWW-Authenticate header construction.
2//!
3//! Implements error responses per RFC 6750 Section 3, including the
4//! `resource_metadata` parameter from RFC 9728 for Protected Resource
5//! Metadata discovery.
6
7use std::fmt;
8
9/// OAuth 2.1 authentication/authorization error.
10///
11/// Each variant maps to a specific HTTP status code and `WWW-Authenticate`
12/// header value per RFC 6750 Section 3.
13#[derive(Debug, Clone)]
14#[non_exhaustive]
15pub enum OAuthError {
16    /// No bearer token was provided in the request.
17    /// Returns HTTP 401 with `WWW-Authenticate: Bearer`.
18    MissingToken,
19
20    /// The provided token is invalid (malformed, signature mismatch, etc.).
21    /// Returns HTTP 401 with `error="invalid_token"`.
22    InvalidToken {
23        /// Human-readable description of why the token is invalid.
24        description: String,
25    },
26
27    /// The token's scopes are insufficient for the requested operation.
28    /// Returns HTTP 403 with `error="insufficient_scope"`.
29    InsufficientScope {
30        /// Scopes required by the operation.
31        required: Vec<String>,
32        /// Scopes present in the token.
33        provided: Vec<String>,
34    },
35
36    /// The token's audience does not match this resource server.
37    /// Returns HTTP 401 with `error="invalid_token"`.
38    InvalidAudience,
39
40    /// The token has expired.
41    /// Returns HTTP 401 with `error="invalid_token"`.
42    ExpiredToken,
43}
44
45impl OAuthError {
46    /// Returns the HTTP status code for this error.
47    ///
48    /// - 401 Unauthorized for authentication failures (missing/invalid/expired token)
49    /// - 403 Forbidden for authorization failures (insufficient scope)
50    pub fn status_code(&self) -> u16 {
51        match self {
52            OAuthError::InsufficientScope { .. } => 403,
53            _ => 401,
54        }
55    }
56
57    /// Builds the `WWW-Authenticate` header value per RFC 6750 Section 3.
58    ///
59    /// When `resource_metadata_url` is provided, includes the `resource_metadata`
60    /// parameter per RFC 9728 so clients can discover the authorization server.
61    pub fn www_authenticate(&self, resource_metadata_url: Option<&str>) -> String {
62        let mut parts = Vec::new();
63
64        // Add resource_metadata parameter if available
65        if let Some(url) = resource_metadata_url {
66            parts.push(auth_parameter("resource_metadata", url));
67        }
68
69        match self {
70            OAuthError::MissingToken => {
71                // RFC 6750 Section 3: If the request lacks any authentication
72                // information, the resource server SHOULD NOT include an error code.
73                if parts.is_empty() {
74                    return "Bearer".to_string();
75                }
76                format!("Bearer {}", parts.join(", "))
77            }
78            OAuthError::InvalidToken { description } => {
79                parts.push("error=\"invalid_token\"".to_string());
80                parts.push(auth_parameter("error_description", description));
81                format!("Bearer {}", parts.join(", "))
82            }
83            OAuthError::InsufficientScope { required, .. } => {
84                parts.push("error=\"insufficient_scope\"".to_string());
85                if !required.is_empty() {
86                    parts.push(auth_parameter("scope", &required.join(" ")));
87                }
88                format!("Bearer {}", parts.join(", "))
89            }
90            OAuthError::InvalidAudience => {
91                parts.push("error=\"invalid_token\"".to_string());
92                parts.push(
93                    "error_description=\"The token audience does not match this resource\""
94                        .to_string(),
95                );
96                format!("Bearer {}", parts.join(", "))
97            }
98            OAuthError::ExpiredToken => {
99                parts.push("error=\"invalid_token\"".to_string());
100                parts.push("error_description=\"The access token has expired\"".to_string());
101                format!("Bearer {}", parts.join(", "))
102            }
103        }
104    }
105}
106
107/// Encode a quoted `WWW-Authenticate` parameter without permitting header
108/// injection. Quotes and backslashes are escaped; control and non-ASCII
109/// characters are replaced with a space or `?` so the result is always a
110/// valid HTTP header value.
111fn auth_parameter(name: &str, value: &str) -> String {
112    let mut escaped = String::with_capacity(value.len());
113    for character in value.chars() {
114        match character {
115            '"' | '\\' => {
116                escaped.push('\\');
117                escaped.push(character);
118            }
119            '\t' => escaped.push(' '),
120            character if character.is_ascii_control() => escaped.push(' '),
121            character if !character.is_ascii() => escaped.push('?'),
122            character => escaped.push(character),
123        }
124    }
125    format!("{name}=\"{escaped}\"")
126}
127
128impl fmt::Display for OAuthError {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        match self {
131            OAuthError::MissingToken => write!(f, "missing bearer token"),
132            OAuthError::InvalidToken { description } => {
133                write!(f, "invalid token: {}", description)
134            }
135            OAuthError::InsufficientScope { required, provided } => write!(
136                f,
137                "insufficient scope: required [{}], provided [{}]",
138                required.join(", "),
139                provided.join(", ")
140            ),
141            OAuthError::InvalidAudience => write!(f, "token audience does not match"),
142            OAuthError::ExpiredToken => write!(f, "token has expired"),
143        }
144    }
145}
146
147impl std::error::Error for OAuthError {}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn test_missing_token_no_metadata() {
155        let err = OAuthError::MissingToken;
156        assert_eq!(err.status_code(), 401);
157        assert_eq!(err.www_authenticate(None), "Bearer");
158    }
159
160    #[test]
161    fn test_missing_token_with_metadata() {
162        let err = OAuthError::MissingToken;
163        assert_eq!(err.status_code(), 401);
164        let header = err.www_authenticate(Some(
165            "https://example.com/.well-known/oauth-protected-resource",
166        ));
167        assert!(header.starts_with("Bearer "));
168        assert!(header.contains("resource_metadata="));
169    }
170
171    #[test]
172    fn test_invalid_token() {
173        let err = OAuthError::InvalidToken {
174            description: "signature mismatch".to_string(),
175        };
176        assert_eq!(err.status_code(), 401);
177        let header = err.www_authenticate(None);
178        assert!(header.contains("error=\"invalid_token\""));
179        assert!(header.contains("error_description=\"signature mismatch\""));
180    }
181
182    #[test]
183    fn test_insufficient_scope() {
184        let err = OAuthError::InsufficientScope {
185            required: vec!["mcp:admin".to_string()],
186            provided: vec!["mcp:read".to_string()],
187        };
188        assert_eq!(err.status_code(), 403);
189        let header = err.www_authenticate(None);
190        assert!(header.contains("error=\"insufficient_scope\""));
191        assert!(header.contains("scope=\"mcp:admin\""));
192    }
193
194    #[test]
195    fn test_invalid_audience() {
196        let err = OAuthError::InvalidAudience;
197        assert_eq!(err.status_code(), 401);
198        let header = err.www_authenticate(None);
199        assert!(header.contains("error=\"invalid_token\""));
200        assert!(header.contains("audience"));
201    }
202
203    #[test]
204    fn test_expired_token() {
205        let err = OAuthError::ExpiredToken;
206        assert_eq!(err.status_code(), 401);
207        let header = err.www_authenticate(None);
208        assert!(header.contains("error=\"invalid_token\""));
209        assert!(header.contains("expired"));
210    }
211
212    #[test]
213    fn test_display() {
214        assert_eq!(OAuthError::MissingToken.to_string(), "missing bearer token");
215        assert_eq!(OAuthError::ExpiredToken.to_string(), "token has expired");
216        assert_eq!(
217            OAuthError::InvalidAudience.to_string(),
218            "token audience does not match"
219        );
220    }
221
222    #[test]
223    fn test_www_authenticate_escapes_untrusted_parameters() {
224        let err = OAuthError::InvalidToken {
225            description: "bad\"\\token\r\nX-Injected: yes".to_string(),
226        };
227        let header =
228            err.www_authenticate(Some("https://example.com/metadata\"\r\nX-Resource: yes"));
229
230        assert!(!header.contains('\r'));
231        assert!(!header.contains('\n'));
232        assert!(header.contains("metadata\\\"  X-Resource"));
233        assert!(header.contains("bad\\\"\\\\token  X-Injected"));
234        assert!(header.parse::<http::HeaderValue>().is_ok());
235    }
236}