Skip to main content

featherbit/plugins/util/
provider_error.rs

1//! The one response shape for "the node could not do its job because the
2//! identity/authorization provider it depends on failed" — discovery, JWKS,
3//! introspection, token endpoint, CAS `/serviceValidate`, an LDAP bind
4//! transport error, a Keycloak/Casdoor callout.
5//!
6//! Such a failure is **not** an authentication or authorization decision, so
7//! it must not look like one: a `502` with `{"error": "provider_error"}` and
8//! no `WWW-Authenticate` challenge, exiting through the node's `error` port.
9//! Before this helper each plugin mirrored its own `denied` shape (`401
10//! unauthorized` + challenge, or `403 access_denied`), which made an IdP
11//! outage indistinguishable from a rejected credential — to API clients and,
12//! for the interactive plugins, to browser users who saw "unauthorized"
13//! instead of a login redirect.
14
15use std::collections::HashMap;
16
17use bytes::Bytes;
18
19use crate::context::{Context, GatewayError};
20use crate::plugins::PluginExecutionError;
21
22/// Prepares the provider-failure response on `ctx` and wraps it in the
23/// `Err` the graph engine routes through the node's `error` port.
24///
25/// `code` is the plugin's own error code (e.g. `OIDC_PROVIDER_ERROR`) —
26/// policies, loggers and traces key on it, so it stays per plugin; `message`
27/// is the operational reason and is echoed verbatim (JSON-escaped) in the
28/// body so a client or the notification log can show it.
29pub fn provider_error(mut ctx: Context, code: &str, message: String) -> PluginExecutionError {
30    ctx.response.status_code = 502;
31    ctx.response.headers.remove("www-authenticate");
32    ctx.response.body = Bytes::from(
33        serde_json::json!({ "error": "provider_error", "message": message }).to_string(),
34    );
35    ctx.response.headers.insert(
36        "content-type".to_string(),
37        vec!["application/json".to_string()],
38    );
39    PluginExecutionError {
40        context: ctx,
41        error: GatewayError {
42            node_id: String::new(),
43            code: code.to_string(),
44            message,
45            metadata: HashMap::new(),
46        },
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::context::{Context, GatewayRequest};
54    use bytes::Bytes;
55    use std::collections::HashMap;
56
57    fn ctx() -> Context {
58        Context::new(GatewayRequest {
59            method: "GET".into(),
60            path: "/".into(),
61            host: "h".into(),
62            scheme: "http".into(),
63            headers: HashMap::new(),
64            query_params: HashMap::new(),
65            body: Bytes::new(),
66            remote_addr: "1.2.3.4:5".into(),
67            protocol: crate::context::Protocol::Http1,
68        })
69    }
70
71    #[test]
72    fn prepares_a_502_provider_error_response() {
73        let err = provider_error(ctx(), "X_PROVIDER_ERROR", "idp unreachable".to_string());
74        testing::assert_provider_error(&err, "X_PROVIDER_ERROR");
75        assert_eq!(err.error.message, "idp unreachable");
76        // node_id is stamped by the graph engine, never by the plugin.
77        assert_eq!(err.error.node_id, "");
78    }
79
80    #[test]
81    fn strips_a_challenge_a_caller_may_have_prepared() {
82        let mut c = ctx();
83        c.response.headers.insert(
84            "www-authenticate".to_string(),
85            vec!["Basic realm=\"x\"".to_string()],
86        );
87        let err = provider_error(c, "X", "m".to_string());
88        assert!(!err
89            .context
90            .response
91            .headers
92            .contains_key("www-authenticate"));
93    }
94
95    #[test]
96    fn message_is_json_escaped_not_interpolated() {
97        let err = provider_error(ctx(), "X", r#"said "no" \ bye"#.to_string());
98        let body: serde_json::Value = serde_json::from_slice(&err.context.response.body).unwrap();
99        assert_eq!(body["message"], r#"said "no" \ bye"#);
100    }
101}
102
103/// Test-only assertions shared by every plugin that emits a provider error,
104/// so the shape is pinned in one place and cannot drift per plugin.
105#[cfg(test)]
106pub mod testing {
107    use crate::plugins::PluginExecutionError;
108
109    /// Asserts `err` has the provider-error shape: `502`, JSON body with
110    /// `error: "provider_error"` and the error's message, no
111    /// `WWW-Authenticate` challenge, and the expected error code.
112    pub fn assert_provider_error(err: &PluginExecutionError, code: &str) {
113        assert_eq!(err.error.code, code);
114        assert_eq!(
115            err.context.response.status_code, 502,
116            "provider failures are 502"
117        );
118        assert!(
119            !err.context
120                .response
121                .headers
122                .contains_key("www-authenticate"),
123            "a provider failure must not challenge the client"
124        );
125        assert_eq!(
126            err.context
127                .response
128                .headers
129                .get("content-type")
130                .map(|v| v[0].as_str()),
131            Some("application/json")
132        );
133        let body: serde_json::Value = serde_json::from_slice(&err.context.response.body)
134            .expect("provider error body is JSON");
135        assert_eq!(body["error"], "provider_error", "{body}");
136        assert_eq!(body["message"], err.error.message, "{body}");
137    }
138}