1use serde_json::Value;
2use thiserror::Error;
3
4use crate::constants::oauth_errors;
5
6#[derive(Debug, Clone, PartialEq, Eq, Error)]
7#[error("{message}")]
8pub struct AuthError {
9 pub message: String,
10 pub code: String,
11 pub status_code: Option<u16>,
12}
13
14impl AuthError {
18 pub fn is_invalid_client(&self) -> bool {
19 self.code == oauth_errors::INVALID_CLIENT
20 }
21 pub fn is_unauthorized_client(&self) -> bool {
22 self.code == oauth_errors::UNAUTHORIZED_CLIENT
23 }
24 pub fn is_invalid_scope(&self) -> bool {
25 self.code == oauth_errors::INVALID_SCOPE
26 }
27 pub fn is_invalid_grant(&self) -> bool {
28 self.code == oauth_errors::INVALID_GRANT
29 }
30 pub fn is_unsupported_grant_type(&self) -> bool {
31 self.code == oauth_errors::UNSUPPORTED_GRANT_TYPE
32 }
33 pub fn is_invalid_request(&self) -> bool {
34 self.code == oauth_errors::INVALID_REQUEST
35 }
36 pub fn is_access_denied(&self) -> bool {
43 self.code == oauth_errors::ACCESS_DENIED
44 }
45 pub fn is_invalid_target(&self) -> bool {
49 self.code == oauth_errors::INVALID_TARGET
50 }
51 pub fn is_server_error(&self) -> bool {
52 self.code == oauth_errors::SERVER_ERROR
53 || self
54 .status_code
55 .is_some_and(|status| (500..600).contains(&(status as u32)))
56 }
57 pub fn is_circuit_open(&self) -> bool {
58 self.code == "circuit_open"
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Error)]
63#[error("{message}")]
64pub struct ConsentRequiredError {
65 pub message: String,
66 pub code: String,
67 pub status_code: Option<u16>,
68 pub service_id: String,
69 pub cause_detail: String,
70 pub consent_url: Option<String>,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Error)]
74#[non_exhaustive]
75pub enum AuthplaneError {
76 #[error(transparent)]
77 Auth(#[from] AuthError),
78 #[error(transparent)]
88 ConsentRequired(#[from] Box<ConsentRequiredError>),
89 #[error("circuit breaker open: AS unavailable")]
92 CircuitOpen,
93}
94
95impl From<ConsentRequiredError> for AuthplaneError {
98 fn from(error: ConsentRequiredError) -> Self {
99 AuthplaneError::ConsentRequired(Box::new(error))
100 }
101}
102
103impl AuthplaneError {
105 pub fn is_circuit_open(&self) -> bool {
106 matches!(self, AuthplaneError::CircuitOpen)
107 }
108 pub fn is_consent_required(&self) -> bool {
109 matches!(self, AuthplaneError::ConsentRequired(_))
110 }
111}
112
113pub fn map_oauth_error(status_code: Option<u16>, payload: &Value) -> AuthplaneError {
114 let body_error_code = payload.get("error").and_then(Value::as_str);
115
116 let description = payload
117 .get("error_description")
118 .and_then(Value::as_str)
119 .unwrap_or("OAuth request failed");
120 let message = description.to_string();
121
122 let resolved_code = if status_code.is_some_and(|status| status >= 500) {
131 oauth_errors::SERVER_ERROR.to_string()
132 } else if status_code == Some(401) && body_error_code.is_none_or(|code| code.trim().is_empty())
133 {
134 oauth_errors::INVALID_CLIENT.to_string()
135 } else {
136 body_error_code
137 .unwrap_or(oauth_errors::INVALID_REQUEST)
138 .to_string()
139 };
140
141 let oauth_code = resolved_code;
142
143 if oauth_code == oauth_errors::CONSENT_REQUIRED
144 || oauth_code == oauth_errors::INTERACTION_REQUIRED
145 {
146 let consent_url = payload
147 .get("consent_url")
148 .and_then(Value::as_str)
149 .map(ToString::to_string);
150 let service_id = first_non_empty_string(payload, &["service_id", "service", "resource"])
151 .unwrap_or("unknown_service")
152 .to_string();
153 let cause_detail = payload
154 .get("cause")
155 .and_then(Value::as_str)
156 .filter(|value| !value.is_empty())
157 .unwrap_or(description)
158 .to_string();
159
160 return AuthplaneError::from(ConsentRequiredError {
161 message,
162 code: oauth_code,
163 status_code,
164 service_id,
165 cause_detail,
166 consent_url,
167 });
168 }
169
170 AuthplaneError::Auth(AuthError {
171 message,
172 code: oauth_code,
173 status_code,
174 })
175}
176
177pub(crate) fn transport_error(message: &str) -> AuthplaneError {
178 AuthplaneError::Auth(AuthError {
179 message: message.to_string(),
180 code: "transport_error".to_string(),
181 status_code: None,
182 })
183}
184
185pub(crate) fn metadata_error(message: &str) -> AuthplaneError {
191 AuthplaneError::Auth(AuthError {
192 message: message.to_string(),
193 code: "metadata_fetch_error".to_string(),
194 status_code: None,
195 })
196}
197
198pub(crate) fn auth_error(code: &str, message: &str) -> AuthplaneError {
205 AuthplaneError::Auth(AuthError {
206 message: message.to_string(),
207 code: code.to_string(),
208 status_code: None,
209 })
210}
211
212pub(crate) fn normalize_issuer(issuer: &str) -> &str {
217 issuer.trim_end_matches('/')
218}
219
220#[derive(Clone, Copy, Debug, PartialEq, Eq)]
222pub(crate) enum QueryComponent {
223 Preserve,
239 Strip,
243}
244
245pub(crate) fn build_well_known_url<F>(
260 base: &str,
261 suffix: &str,
262 query: QueryComponent,
263 invalid_url_code: &str,
264 invalid_url_message: F,
265) -> Result<String, AuthplaneError>
266where
267 F: FnOnce() -> String,
268{
269 let parsed =
270 url::Url::parse(base).map_err(|_| auth_error(invalid_url_code, &invalid_url_message()))?;
271 let path = parsed.path().trim_end_matches('/');
277 let well_known_path = if path.is_empty() {
278 format!("/.well-known/{suffix}")
279 } else if path.starts_with('/') {
280 format!("/.well-known/{suffix}{path}")
284 } else {
285 format!("/.well-known/{suffix}/{path}")
291 };
292
293 let mut rebuilt = parsed;
294 rebuilt.set_path(&well_known_path);
295 if query == QueryComponent::Strip || rebuilt.query() == Some("") {
299 rebuilt.set_query(None);
300 }
301 rebuilt.set_fragment(None);
302 Ok(rebuilt.to_string())
303}
304
305pub(crate) fn validation_error(message: &str) -> AuthplaneError {
306 AuthplaneError::Auth(AuthError {
307 message: format!("authplane: {message}"),
308 code: "validation_error".to_string(),
309 status_code: None,
310 })
311}
312
313pub(crate) fn protocol_error(message: &str) -> AuthplaneError {
314 AuthplaneError::Auth(AuthError {
315 message: format!("authplane: {message}"),
316 code: "protocol_error".to_string(),
317 status_code: None,
318 })
319}
320
321fn first_non_empty_string<'a>(payload: &'a Value, keys: &[&str]) -> Option<&'a str> {
322 for key in keys {
323 if let Some(value) = payload.get(key).and_then(Value::as_str)
324 && !value.is_empty()
325 {
326 return Some(value);
327 }
328 }
329 None
330}
331
332#[cfg(test)]
333mod tests {
334 use serde_json::json;
335
336 use crate::{AuthplaneError, map_oauth_error};
337
338 #[test]
343 fn error_enum_stays_under_the_result_large_err_threshold() {
344 assert!(
345 size_of::<AuthplaneError>() < 128,
346 "AuthplaneError grew to {} bytes; box the new payload instead of \
347 allowing clippy::result_large_err",
348 size_of::<AuthplaneError>()
349 );
350 }
351
352 #[test]
353 fn consent_required_uses_unknown_service_fallback() {
354 let payload = json!({
355 "error": "consent_required",
356 "error_description": "Consent required"
357 });
358
359 let mapped = map_oauth_error(Some(400), &payload);
360 let AuthplaneError::ConsentRequired(consent) = mapped else {
361 panic!("expected consent required");
362 };
363
364 assert_eq!(consent.service_id, "unknown_service");
365 assert_eq!(consent.cause_detail, "Consent required");
366 }
367
368 #[test]
369 fn other_errors_map_to_auth_error() {
370 let payload = json!({
371 "error": "invalid_scope",
372 "error_description": "scope missing"
373 });
374
375 let mapped = map_oauth_error(Some(400), &payload);
376 let AuthplaneError::Auth(auth_error) = mapped else {
377 panic!("expected auth error");
378 };
379 assert_eq!(auth_error.code, "invalid_scope");
380 assert_eq!(auth_error.message, "scope missing");
381 }
382
383 #[test]
384 fn interaction_required_uses_resource_and_description_fallbacks() {
385 let payload = json!({
386 "error": "interaction_required",
387 "error_description": "User action needed",
388 "resource": "calendar"
389 });
390
391 let mapped = map_oauth_error(Some(400), &payload);
392 let AuthplaneError::ConsentRequired(consent) = mapped else {
393 panic!("expected consent required");
394 };
395 assert_eq!(consent.service_id, "calendar");
396 assert_eq!(consent.cause_detail, "User action needed");
397 assert_eq!(consent.consent_url, None);
398 }
399
400 #[test]
401 fn consent_required_uses_default_message_when_description_missing() {
402 let payload = json!({
403 "error": "consent_required",
404 "service_id": "drive"
405 });
406
407 let mapped = map_oauth_error(Some(400), &payload);
408 let AuthplaneError::ConsentRequired(consent) = mapped else {
409 panic!("expected consent required");
410 };
411 assert_eq!(consent.message, "OAuth request failed");
412 assert_eq!(consent.cause_detail, "OAuth request failed");
413 }
414
415 #[test]
416 fn http_5xx_maps_to_server_error_regardless_of_body_code() {
417 let payload = json!({});
421 let mapped = map_oauth_error(Some(503), &payload);
422 let AuthplaneError::Auth(auth_error) = mapped else {
423 panic!("expected auth error variant");
424 };
425 assert_eq!(auth_error.code, "server_error");
426 assert!(auth_error.is_server_error());
427
428 let misleading = json!({ "error": "invalid_grant" });
432 let mapped = map_oauth_error(Some(502), &misleading);
433 let AuthplaneError::Auth(auth_error) = mapped else {
434 panic!("expected auth error variant");
435 };
436 assert_eq!(auth_error.code, "server_error");
437 }
438
439 #[test]
440 fn bare_401_with_no_body_error_maps_to_invalid_client() {
441 let payload = json!({});
444 let mapped = map_oauth_error(Some(401), &payload);
445 let AuthplaneError::Auth(auth_error) = mapped else {
446 panic!("expected auth error variant");
447 };
448 assert_eq!(auth_error.code, "invalid_client");
449 assert!(auth_error.is_invalid_client());
450 }
451
452 #[test]
453 fn populated_401_with_body_error_uses_body_code() {
454 let payload = json!({
458 "error": "invalid_grant",
459 "error_description": "Refresh token expired"
460 });
461 let mapped = map_oauth_error(Some(401), &payload);
462 let AuthplaneError::Auth(auth_error) = mapped else {
463 panic!("expected auth error variant");
464 };
465 assert_eq!(auth_error.code, "invalid_grant");
466 }
467
468 use super::{QueryComponent, build_well_known_url, normalize_issuer};
471
472 #[test]
473 fn normalize_issuer_strips_single_trailing_slash() {
474 assert_eq!(
475 normalize_issuer("https://auth.example.com/"),
476 "https://auth.example.com"
477 );
478 }
479
480 #[test]
481 fn normalize_issuer_is_idempotent_without_trailing_slash() {
482 assert_eq!(
483 normalize_issuer("https://auth.example.com"),
484 "https://auth.example.com"
485 );
486 }
487
488 #[test]
489 fn normalize_issuer_treats_pre_and_post_trim_forms_as_equal() {
490 assert_eq!(
494 normalize_issuer("https://auth.example.com/"),
495 normalize_issuer("https://auth.example.com")
496 );
497 }
498
499 #[test]
500 fn build_well_known_url_appends_suffix_without_issuer_path() {
501 let url = build_well_known_url(
502 "https://auth.example.com",
503 "oauth-authorization-server",
504 QueryComponent::Strip,
505 "metadata_fetch_error",
506 || "issuer must be an absolute URL".to_string(),
507 )
508 .expect("valid base");
509 assert_eq!(
510 url,
511 "https://auth.example.com/.well-known/oauth-authorization-server"
512 );
513 }
514
515 #[test]
516 fn build_well_known_url_splices_suffix_before_issuer_path() {
517 let url = build_well_known_url(
518 "https://auth.example.com/tenant-a",
519 "oauth-authorization-server",
520 QueryComponent::Strip,
521 "metadata_fetch_error",
522 || "issuer must be an absolute URL".to_string(),
523 )
524 .expect("valid base");
525 assert_eq!(
526 url,
527 "https://auth.example.com/.well-known/oauth-authorization-server/tenant-a"
528 );
529 }
530
531 #[test]
532 fn build_well_known_url_preserves_query_and_strips_fragment() {
533 let url = build_well_known_url(
537 "https://api.example.com/v1/mcp?token=abc#frag",
538 "oauth-protected-resource",
539 QueryComponent::Preserve,
540 "invalid_resource",
541 || "invalid resource URL".to_string(),
542 )
543 .expect("valid base");
544 assert_eq!(
545 url,
546 "https://api.example.com/.well-known/oauth-protected-resource/v1/mcp?token=abc"
547 );
548 }
549
550 #[test]
551 fn build_well_known_url_strip_mode_drops_query() {
552 let url = build_well_known_url(
555 "https://auth.example.com/tenant-a?q=1#frag",
556 "oauth-authorization-server",
557 QueryComponent::Strip,
558 "metadata_fetch_error",
559 || "issuer must be an absolute URL".to_string(),
560 )
561 .expect("valid base");
562 assert_eq!(
563 url,
564 "https://auth.example.com/.well-known/oauth-authorization-server/tenant-a"
565 );
566 }
567
568 #[test]
569 fn build_well_known_url_preserve_mode_drops_empty_query() {
570 let url = build_well_known_url(
575 "https://api.example.com/mcp?",
576 "oauth-protected-resource",
577 QueryComponent::Preserve,
578 "invalid_resource",
579 || "invalid resource URL".to_string(),
580 )
581 .expect("valid base");
582 assert_eq!(
583 url,
584 "https://api.example.com/.well-known/oauth-protected-resource/mcp"
585 );
586 }
587
588 #[test]
589 fn build_well_known_url_keeps_leading_empty_path_segment() {
590 let doubled = build_well_known_url(
593 "https://api.example.com//mcp",
594 "oauth-protected-resource",
595 QueryComponent::Preserve,
596 "invalid_resource",
597 || "invalid resource URL".to_string(),
598 )
599 .expect("valid base");
600 let single = build_well_known_url(
601 "https://api.example.com/mcp",
602 "oauth-protected-resource",
603 QueryComponent::Preserve,
604 "invalid_resource",
605 || "invalid resource URL".to_string(),
606 )
607 .expect("valid base");
608 assert_eq!(
609 doubled,
610 "https://api.example.com/.well-known/oauth-protected-resource//mcp"
611 );
612 assert_eq!(
613 single,
614 "https://api.example.com/.well-known/oauth-protected-resource/mcp"
615 );
616 assert_ne!(doubled, single);
617 }
618
619 #[test]
620 fn build_well_known_url_rejects_relative_base_with_supplied_code() {
621 let error = build_well_known_url(
622 "/relative/path",
623 "oauth-authorization-server",
624 QueryComponent::Strip,
625 "metadata_fetch_error",
626 || "issuer must be an absolute URL".to_string(),
627 )
628 .expect_err("relative base must be rejected");
629 let AuthplaneError::Auth(auth_error) = error else {
630 panic!("expected auth error");
631 };
632 assert_eq!(auth_error.code, "metadata_fetch_error");
633 assert_eq!(auth_error.message, "issuer must be an absolute URL");
634 }
635
636 #[test]
637 fn build_well_known_url_does_not_invoke_message_thunk_on_success() {
638 use std::cell::Cell;
642 let invoked = Cell::new(false);
643 let _ = build_well_known_url(
644 "https://api.example.com/mcp",
645 "oauth-protected-resource",
646 QueryComponent::Preserve,
647 "invalid_resource",
648 || {
649 invoked.set(true);
650 "should not be called".to_string()
651 },
652 )
653 .expect("valid base");
654 assert!(
655 !invoked.get(),
656 "message thunk must not run on successful parse"
657 );
658 }
659}