1use std::fmt;
8
9#[derive(Debug, Clone)]
14#[non_exhaustive]
15pub enum OAuthError {
16 MissingToken,
19
20 InvalidToken {
23 description: String,
25 },
26
27 InsufficientScope {
30 required: Vec<String>,
32 provided: Vec<String>,
34 },
35
36 InvalidAudience,
39
40 ExpiredToken,
43}
44
45impl OAuthError {
46 pub fn status_code(&self) -> u16 {
51 match self {
52 OAuthError::InsufficientScope { .. } => 403,
53 _ => 401,
54 }
55 }
56
57 pub fn www_authenticate(&self, resource_metadata_url: Option<&str>) -> String {
62 let mut parts = Vec::new();
63
64 if let Some(url) = resource_metadata_url {
66 parts.push(auth_parameter("resource_metadata", url));
67 }
68
69 match self {
70 OAuthError::MissingToken => {
71 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
107fn 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}