Skip to main content

authplane_sdk/
errors.rs

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
14/// Convenience predicates for the common error categories.
15/// These check the `code` field so callers can pattern-match on OAuth error
16/// codes without string comparisons.
17impl 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    /// `access_denied` — the AS refused the request on policy grounds.
37    /// On a cross-client token exchange this means the exchanging client
38    /// is not allowlisted on the target Resource; the operator has to add
39    /// it (`PATCH /admin/resources/{id}` with
40    /// `policy.exchange.allowed_client_ids`). Re-prompting the user does
41    /// not help, which is what separates it from `consent_required`.
42    pub fn is_access_denied(&self) -> bool {
43        self.code == oauth_errors::ACCESS_DENIED
44    }
45    /// `invalid_target` (RFC 8707 §2.2) — the `resource` parameter does
46    /// not match a granted resource byte for byte (a trailing slash is
47    /// enough).
48    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    /// The payload is boxed. `ConsentRequiredError` carries six fields
79    /// (128 bytes) against `AuthError`'s three (56), so leaving it inline
80    /// makes every `Result<_, AuthplaneError>` in the crate — overwhelmingly
81    /// the success path — as wide as the rarest error. Boxing keeps the enum
82    /// at 64 bytes.
83    ///
84    /// Construct with `AuthplaneError::from(consent)` or `consent.into()`;
85    /// `match` arms bind a `Box<ConsentRequiredError>` and reach the fields
86    /// through `Deref`, so patterns need no change.
87    #[error(transparent)]
88    ConsentRequired(#[from] Box<ConsentRequiredError>),
89    /// Circuit breaker is open — the authorization server is considered
90    /// unavailable.
91    #[error("circuit breaker open: AS unavailable")]
92    CircuitOpen,
93}
94
95/// Boxing companion for the derived `From<Box<ConsentRequiredError>>`, so
96/// call sites and `?` keep converting an unboxed `ConsentRequiredError`.
97impl From<ConsentRequiredError> for AuthplaneError {
98    fn from(error: ConsentRequiredError) -> Self {
99        AuthplaneError::ConsentRequired(Box::new(error))
100    }
101}
102
103/// Convenience predicates on `AuthplaneError` for common error categories.
104impl 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    // Two structural cases are checked BEFORE the RFC 6749 §5.2
123    // `error`-code switch (status-first classifier):
124    //   • status >= 500 → server-side regardless of the (often-missing)
125    //     error body. Surface as `code = "server_error"` so callers'
126    //     `AuthError::is_server_error()` predicate fires correctly and
127    //     circuit-breaker policy can react.
128    //   • status == 401 with no error body → AS rejected client
129    //     authentication; the typed handle is invalid_client.
130    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
185/// Build an `AuthplaneError::Auth(AuthError {...})` with the
186/// `metadata_fetch_error` code. Centralises the construction that was
187/// duplicated between `metadata.rs` and `cache/metadata_cache.rs`; new
188/// metadata-fetch failure paths should route through here so the
189/// `code` token never drifts.
190pub(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
198/// Build a generic `AuthplaneError::Auth(AuthError {...})` from a free-form
199/// code + message pair. Replaces hand-written struct literals at the
200/// call sites that don't have a more specific helper. Use this only when
201/// a specific helper (`transport_error`, `metadata_error`,
202/// `validation_error`, `protocol_error`) doesn't apply — those carry
203/// the canonical code strings and should remain the first-choice paths.
204pub(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
212/// Trim a single trailing `/` from an issuer string. RFC 8414 §2
213/// treats `https://example.com` and `https://example.com/` as equivalent;
214/// every call site that compares or stores an issuer should funnel
215/// through here so the comparison shape stays uniform.
216pub(crate) fn normalize_issuer(issuer: &str) -> &str {
217    issuer.trim_end_matches('/')
218}
219
220/// How [`build_well_known_url`] treats the base URL's query component.
221#[derive(Clone, Copy, Debug, PartialEq, Eq)]
222pub(crate) enum QueryComponent {
223    /// RFC 9728 §3 inserts the well-known suffix "between the host
224    /// component and the path and/or query components, if any" — a query
225    /// on the resource identifier survives into the derived PRM URL.
226    /// The query is a legal part of the identifier: RFC 8707 §2 states
227    /// the SHOULD NOT and its scoping exception in the same sentence,
228    /// and RFC 9728 §1.2 carries that carve-out forward.
229    ///
230    /// Two caveats. A bare trailing `?` (empty query) is treated as no
231    /// query — the empty string identifies nothing, so it resolves to
232    /// the query-less document URL. And the query rides through
233    /// `url::Url`'s serializer rather than being spliced from the
234    /// configured bytes, so legal-but-normalizable octets (a raw space,
235    /// `'` on a special scheme) surface percent-encoded in the derived
236    /// URL while `ProtectedResourceMetadata::resource` keeps the
237    /// operator's original bytes.
238    Preserve,
239    /// RFC 8414 §2 defines the issuer identifier with no query or
240    /// fragment components, so the AS-metadata derivation drops a query
241    /// rather than propagating an out-of-spec issuer shape.
242    Strip,
243}
244
245/// Build the absolute URL for a `/.well-known/<suffix>` document scoped
246/// to a base URL's path. Shared between `build_metadata_url`
247/// (RFC 8414 §3) and `build_prm_url` (RFC 9728 §3) — both apply the
248/// same template (parse → remove the terminating slash → splice the
249/// well-known prefix → clear the fragment); the suffix and the query
250/// handling differ per caller (see [`QueryComponent`]).
251///
252/// `suffix` is the well-known segment without leading or trailing
253/// slashes (e.g. `"oauth-authorization-server"`,
254/// `"oauth-protected-resource"`).
255///
256/// `invalid_url_message` is invoked only on the error path so callers
257/// can embed the offending input via `format!` without paying for the
258/// allocation on every successful parse.
259pub(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    // RFC 8414 §3.1 / RFC 9728 §3.1 remove the *terminating* "/" from
272    // the path before inserting the well-known suffix after the host.
273    // Only trailing slashes come off: the previous `trim_matches('/')`
274    // stripped leading slashes too, collapsing `//mcp` onto `/mcp` —
275    // two distinct identifiers deriving one metadata document URL.
276    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        // A URL with an authority always exposes a '/'-prefixed path,
281        // so plain concatenation keeps exactly one separator and
282        // preserves any leading empty segment.
283        format!("/.well-known/{suffix}{path}")
284    } else {
285        // Cannot-be-a-base URLs (e.g. `urn:`) expose a slash-less path;
286        // re-add a separator between suffix and path. The derivation is
287        // not meaningful for such identifiers — RFC 9728 §3 presumes a
288        // host component — and the construction path does not yet
289        // reject them.
290        format!("/.well-known/{suffix}/{path}")
291    };
292
293    let mut rebuilt = parsed;
294    rebuilt.set_path(&well_known_path);
295    // In Preserve mode an *empty* query (a bare trailing `?`) is still
296    // dropped: `url::Url` parses it as `Some("")` and would re-serialize
297    // the lone `?` into a document URL no client re-derives.
298    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    /// Guards the boxing on `ConsentRequired`. 128 bytes is clippy's
339    /// `result_large_err` threshold; crossing it makes the lint fire on
340    /// every `Result<_, AuthplaneError>` in the crate, which is what the
341    /// module-wide `#![allow]`s used to paper over.
342    #[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        // The mapping short-circuits on status >= 500 before reaching the
418        // error-code switch — a 503 with no usable body still
419        // surfaces as ServerError so circuit-breaker policy can trip.
420        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        // Even if the AS returned a misleading `error=invalid_grant` on
429        // a 502, the status code wins — server-side outage trumps the
430        // wire error code.
431        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        // A bodyless 401 is the AS rejecting client authentication;
442        // the typed `invalid_client` code is the catch handle.
443        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        // When the body DOES carry an error code, that wins over the
455        // 401-bare-fallback. Only the bodyless case dispatches to
456        // invalid_client.
457        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    // --- helpers added in the audit-followup sweep ---
469
470    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        // RFC 8414 §2 equivalence: the two forms must collapse to the
491        // same comparison key. This pins the property the validator
492        // depends on.
493        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        // RFC 9728 §3: the well-known suffix goes between the host and
534        // "the path and/or query components, if any" — the query is part
535        // of the derived URL, not noise to normalize away.
536        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        // RFC 8414 §2 gives the issuer identifier no query component;
553        // the AS-metadata caller opts into stripping.
554        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        // A bare trailing `?` parses as `query() == Some("")`; carrying
571        // it forward would serialize a lone `?` into the document URL.
572        // Preserve mode still resolves the empty query to the query-less
573        // URL.
574        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        // §3.1 removes only the *terminating* slash. `//mcp` and `/mcp`
591        // are distinct identifiers and must derive distinct documents.
592        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        // The lazy-message contract: callers can embed expensive
639        // formatting (URL interpolation, etc.) in the error message
640        // without paying for it on every successful parse.
641        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}