Skip to main content

featherbit/plugins/native/
authz_keycloak.rs

1//! Keycloak UMA authorization plugin (`authz-keycloak`).
2//!
3//! Ports a faithful subset of Apache APISIX's `authz-keycloak` plugin: the
4//! **UMA 2.0 permission check** against a Keycloak token endpoint. For each
5//! request the plugin takes the caller's bearer access token and asks Keycloak
6//! whether it grants the configured permissions, using the
7//! `urn:ietf:params:oauth:grant-type:uma-ticket` grant with
8//! `response_mode=decision` — exactly the request APISIX's `evaluate_permissions`
9//! builds. A `200` decision allows the request; a `401`/`403` is Keycloak
10//! refusing it; any other status means no decision was obtained at all (see
11//! [`classify_decision`]).
12//!
13//! ## Implemented subset
14//!
15//! - Static, pre-configured `permissions` (with optional `http_method_as_scope`).
16//! - `policy_enforcement_mode` (`ENFORCING` / `PERMISSIVE`) for the empty-permission case.
17//! - `ssl_verify` and `timeout`.
18//!
19//! ## Deliberately NOT ported (documented deviations)
20//!
21//! - **Discovery** (`discovery` URL): configure `token_endpoint` directly.
22//! - **`lazy_load_paths`** / resource-registration lookups and the
23//!   service-account (`client_credentials`) token dance — no dynamic resource
24//!   resolution.
25//! - **`password_grant_token_generation_incoming_uri`** token minting.
26//! - Response/token caching and `access_denied_redirect_uri` redirects.
27//!
28//! Deliberate denials (missing bearer, no configured permission under
29//! `ENFORCING`, or a `401`/`403` UMA decision) map to `403` and exit through
30//! the dedicated **`denied`** port. Genuine failures exit through the ordinary
31//! **`error`** port instead, since the node could not do its job: the Keycloak
32//! token endpoint unreachable or timing out, *and* any unexpected status from
33//! it (`400`, `404` from a misconfigured endpoint path, `5xx`). Only a status
34//! Keycloak uses to express an access verdict is treated as a verdict —
35//! otherwise a broken deployment would masquerade as a legitimate 403.
36
37use async_trait::async_trait;
38use bytes::Bytes;
39use std::collections::HashMap;
40use std::sync::Arc;
41use std::time::Duration;
42
43use crate::context::Context;
44use crate::outbound::{OutboundClient, OutboundError, OutboundRequest};
45use crate::plugins::resources::PluginResources;
46use crate::plugins::{Plugin, PluginOutput, PluginResult};
47
48const UMA_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:uma-ticket";
49
50/// Performs a Keycloak UMA permission check per request.
51pub struct AuthzKeycloakPlugin {
52    /// Keycloak token endpoint (`.../protocol/openid-connect/token`).
53    token_endpoint: String,
54    /// OAuth client id, sent as the UMA `audience`.
55    client_id: String,
56    /// Statically configured permissions (`resource` or `resource#scope`).
57    permissions: Vec<String>,
58    /// `ENFORCING` (default) denies when no permission is configured;
59    /// `PERMISSIVE` allows.
60    enforcing: bool,
61    /// When true, the request method is appended as the permission scope.
62    http_method_as_scope: bool,
63    /// TLS certificate verification for the callout.
64    ssl_verify: bool,
65    /// Whole-call timeout for the callout.
66    timeout: Duration,
67    /// Shared pooled outbound HTTP client.
68    outbound: Arc<OutboundClient>,
69}
70
71impl AuthzKeycloakPlugin {
72    /// Builds the plugin from node config.
73    ///
74    /// Accepted keys:
75    /// - `token_endpoint` (string, **required**): Keycloak token endpoint URL.
76    ///   (APISIX's `discovery` auto-resolution is not supported.)
77    /// - `client_id` (string, **required**): OAuth client id, sent as the UMA
78    ///   `audience`.
79    /// - `permissions` (array of strings, default `[]`): requested permissions,
80    ///   each `resource` or `resource#scope`.
81    /// - `policy_enforcement_mode` (string, default `"ENFORCING"`): `ENFORCING`
82    ///   denies when `permissions` is empty; `PERMISSIVE` allows without a callout.
83    /// - `http_method_as_scope` (bool, default `false`): append the request
84    ///   method as the scope of each permission.
85    /// - `ssl_verify` (bool, default `true`): verify the endpoint's TLS certificate.
86    /// - `timeout` (integer ms, default `3000`): callout timeout.
87    ///
88    /// ```yaml
89    /// - id: authz
90    ///   type: authz-keycloak
91    ///   config:
92    ///     token_endpoint: https://kc.example.com/realms/myrealm/protocol/openid-connect/token
93    ///     client_id: my-api
94    ///     permissions: ["Default Resource#read"]
95    ///     policy_enforcement_mode: ENFORCING
96    ///     ssl_verify: true
97    ///     timeout: 3000
98    /// ```
99    pub fn from_config(
100        config: &HashMap<String, serde_json::Value>,
101        resources: &Arc<PluginResources>,
102    ) -> Result<Self, String> {
103        let token_endpoint = config
104            .get("token_endpoint")
105            .and_then(|v| v.as_str())
106            .filter(|s| !s.is_empty())
107            .ok_or_else(|| {
108                "authz-keycloak requires 'token_endpoint' (discovery is not supported)".to_string()
109            })?
110            .to_string();
111
112        let client_id = config
113            .get("client_id")
114            .and_then(|v| v.as_str())
115            .filter(|s| !s.is_empty())
116            .ok_or_else(|| "authz-keycloak requires 'client_id'".to_string())?
117            .to_string();
118
119        let permissions: Vec<String> = config
120            .get("permissions")
121            .and_then(|v| v.as_array())
122            .map(|seq| {
123                seq.iter()
124                    .filter_map(|v| v.as_str().map(String::from))
125                    .collect()
126            })
127            .unwrap_or_default();
128
129        let mode = config
130            .get("policy_enforcement_mode")
131            .and_then(|v| v.as_str())
132            .unwrap_or("ENFORCING");
133        let enforcing = match mode {
134            "ENFORCING" => true,
135            "PERMISSIVE" => false,
136            other => {
137                return Err(format!(
138                    "authz-keycloak: invalid policy_enforcement_mode '{other}' \
139                     (expected ENFORCING or PERMISSIVE)"
140                ))
141            }
142        };
143
144        let http_method_as_scope = config
145            .get("http_method_as_scope")
146            .and_then(|v| v.as_bool())
147            .unwrap_or(false);
148
149        let ssl_verify = config
150            .get("ssl_verify")
151            .and_then(|v| v.as_bool())
152            .unwrap_or(true);
153
154        let timeout_ms = config
155            .get("timeout")
156            .and_then(|v| v.as_u64())
157            .unwrap_or(3000);
158
159        Ok(Self {
160            token_endpoint,
161            client_id,
162            permissions,
163            enforcing,
164            http_method_as_scope,
165            ssl_verify,
166            timeout: Duration::from_millis(timeout_ms),
167            outbound: resources.outbound.clone(),
168        })
169    }
170
171    /// Builds the 403 denial and exits on the `denied` port. Reserved for
172    /// deliberate denials — a missing bearer token, no permission configured
173    /// under `ENFORCING`, or Keycloak actively refusing the UMA decision.
174    fn deny(ctx: Context, message: impl Into<String>) -> PluginResult {
175        // The reason never reaches the client (the `denied` port carries no
176        // error record, unlike `Err`), but it's still useful for operators
177        // debugging why a request was denied.
178        tracing::debug!("authz-keycloak: denying request: {}", message.into());
179        let mut ctx = ctx;
180        ctx.response.status_code = 403;
181        ctx.response.body =
182            Bytes::from(r#"{"error":"access_denied","error_description":"not_authorized"}"#);
183        ctx.response.headers.insert(
184            "content-type".to_string(),
185            vec!["application/json".to_string()],
186        );
187        Ok(PluginOutput::on_port(ctx, "denied"))
188    }
189
190    /// Builds a genuine infrastructure-failure `Err` (the Keycloak token
191    /// endpoint unreachable, timed out, or answering with a status that is not
192    /// a decision) — exits through the `error` port because the node could not
193    /// do its job, unlike `deny` which is a deliberate, client-facing decision.
194    /// The prepared response is the shared `502 provider_error` shape, not a
195    /// `403` that would hide a broken deployment behind a plausible denial.
196    fn callout_error(ctx: Context, message: String) -> PluginResult {
197        Err(crate::plugins::util::provider_error::provider_error(
198            ctx,
199            "AUTHZ_KEYCLOAK_ERROR",
200            message,
201        ))
202    }
203}
204
205/// Extracts the bearer token from the `Authorization` header, normalizing to a
206/// `Bearer `-prefixed value (mirroring APISIX's `fetch_jwt_token`).
207fn fetch_bearer(ctx: &Context) -> Option<String> {
208    let raw = ctx
209        .request
210        .headers
211        .get("authorization")
212        .and_then(|v| v.first())?
213        .trim();
214    if raw.is_empty() {
215        return None;
216    }
217    let lower = raw.to_ascii_lowercase();
218    if lower.starts_with("bearer ") {
219        Some(raw.to_string())
220    } else {
221        Some(format!("Bearer {raw}"))
222    }
223}
224
225/// Applies `http_method_as_scope`: appends `#<method>` to each permission, or
226/// `, <method>` when a scope is already present (matching APISIX's logic).
227fn scoped_permissions(permissions: &[String], method: Option<&str>) -> Vec<String> {
228    match method {
229        None => permissions.to_vec(),
230        Some(m) => permissions
231            .iter()
232            .map(|p| {
233                if p.contains('#') {
234                    format!("{p}, {m}")
235                } else {
236                    format!("{p}#{m}")
237                }
238            })
239            .collect(),
240    }
241}
242
243/// Encodes the UMA permission-check request body as
244/// `application/x-www-form-urlencoded`, repeating `permission` per entry.
245fn encode_uma_body(client_id: &str, permissions: &[String]) -> String {
246    let mut pairs: Vec<(String, String)> = vec![
247        ("grant_type".to_string(), UMA_GRANT_TYPE.to_string()),
248        ("audience".to_string(), client_id.to_string()),
249        ("response_mode".to_string(), "decision".to_string()),
250    ];
251    for p in permissions {
252        pairs.push(("permission".to_string(), p.clone()));
253    }
254    pairs
255        .iter()
256        .map(|(k, v)| format!("{}={}", form_encode(k), form_encode(v)))
257        .collect::<Vec<_>>()
258        .join("&")
259}
260
261/// Percent-encodes a value for `application/x-www-form-urlencoded` bodies
262/// (unreserved characters pass through; space becomes `+`).
263fn form_encode(s: &str) -> String {
264    let mut out = String::with_capacity(s.len());
265    for &b in s.as_bytes() {
266        match b {
267            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
268                out.push(b as char)
269            }
270            b' ' => out.push('+'),
271            _ => out.push_str(&format!("%{b:02X}")),
272        }
273    }
274    out
275}
276
277/// What a Keycloak UMA `response_mode=decision` reply means.
278#[derive(Debug, PartialEq, Eq)]
279enum Decision {
280    /// `200` — the decision endpoint granted the requested permissions.
281    Granted,
282    /// Keycloak evaluated the request and refused it: `403` (`access_denied`)
283    /// or `401` (the bearer token was rejected). A deliberate, client-facing
284    /// decision → the `denied` port.
285    Denied,
286    /// Anything else — `5xx`, or a `4xx` that means the *request to Keycloak*
287    /// was wrong rather than the client's access (`400 invalid_grant`,
288    /// `404` from a misconfigured token endpoint). The node never obtained a
289    /// decision → the `error` port.
290    Unexpected,
291}
292
293/// Classifies a Keycloak UMA response status.
294///
295/// The split matters: a misconfigured `token_endpoint` answering `404`, or a
296/// Keycloak having a bad day answering `502`, is *not* "this client may not
297/// pass" — reporting it as a denial hides a broken deployment behind a
298/// plausible-looking 403.
299fn classify_decision(status: u16) -> Decision {
300    match status {
301        200 => Decision::Granted,
302        401 | 403 => Decision::Denied,
303        _ => Decision::Unexpected,
304    }
305}
306
307#[async_trait]
308impl Plugin for AuthzKeycloakPlugin {
309    fn plugin_type(&self) -> &str {
310        "authz-keycloak"
311    }
312
313    async fn execute(&self, ctx: Context) -> PluginResult {
314        // Empty permissions: deny under ENFORCING, allow under PERMISSIVE.
315        if self.permissions.is_empty() {
316            return if self.enforcing {
317                Self::deny(ctx, "no permissions configured (ENFORCING)")
318            } else {
319                Ok(PluginOutput::success(ctx))
320            };
321        }
322
323        let token = match fetch_bearer(&ctx) {
324            Some(t) => t,
325            None => return Self::deny(ctx, "missing bearer token"),
326        };
327
328        let method_scope = if self.http_method_as_scope {
329            Some(ctx.request.method.as_str())
330        } else {
331            None
332        };
333        let permissions = scoped_permissions(&self.permissions, method_scope);
334        let body = encode_uma_body(&self.client_id, &permissions);
335
336        let request = OutboundRequest {
337            method: http::Method::POST,
338            url: self.token_endpoint.clone(),
339            headers: vec![
340                (
341                    "content-type".to_string(),
342                    "application/x-www-form-urlencoded".to_string(),
343                ),
344                ("authorization".to_string(), token),
345            ],
346            body: Bytes::from(body),
347            timeout: self.timeout,
348            ssl_verify: self.ssl_verify,
349            tls: None,
350        };
351
352        match self.outbound.request(request).await {
353            Ok(resp) => match classify_decision(resp.status) {
354                Decision::Granted => Ok(PluginOutput::success(ctx)),
355                Decision::Denied => Self::deny(
356                    ctx,
357                    format!("Keycloak denied permission (status {})", resp.status),
358                ),
359                // The node never got a decision — a broken endpoint or a
360                // failing Keycloak, not a verdict about this client.
361                Decision::Unexpected => Self::callout_error(
362                    ctx,
363                    format!(
364                        "unexpected status {} from the Keycloak token endpoint \
365                         (expected 200/401/403)",
366                        resp.status
367                    ),
368                ),
369            },
370            Err(e) => {
371                let detail = match &e {
372                    OutboundError::Timeout(d) => format!("Keycloak request timed out after {d:?}"),
373                    OutboundError::InvalidRequest(m) => format!("invalid Keycloak request: {m}"),
374                    OutboundError::Transport(m) => format!("Keycloak request failed: {m}"),
375                };
376                Self::callout_error(ctx, detail)
377            }
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
386
387    fn ctx_with_auth(auth: Option<&str>) -> Context {
388        let mut headers = HashMap::new();
389        if let Some(a) = auth {
390            headers.insert("authorization".to_string(), vec![a.to_string()]);
391        }
392        Context {
393            request: GatewayRequest {
394                method: "GET".to_string(),
395                path: "/data".to_string(),
396                host: "h".to_string(),
397                scheme: "http".to_string(),
398                headers,
399                query_params: HashMap::new(),
400                body: Bytes::new(),
401                remote_addr: "1.2.3.4:5".to_string(),
402                protocol: Protocol::Http1,
403            },
404            response: GatewayResponse {
405                status_code: 0,
406                headers: HashMap::new(),
407                body: Bytes::new(),
408                stream: None,
409            },
410            message: HashMap::new(),
411            errors: Vec::new(),
412        }
413    }
414
415    #[test]
416    fn test_fetch_bearer_normalizes_prefix() {
417        assert_eq!(
418            fetch_bearer(&ctx_with_auth(Some("Bearer abc"))).as_deref(),
419            Some("Bearer abc")
420        );
421        // missing prefix gets one
422        assert_eq!(
423            fetch_bearer(&ctx_with_auth(Some("abc"))).as_deref(),
424            Some("Bearer abc")
425        );
426        // lowercase prefix preserved
427        assert_eq!(
428            fetch_bearer(&ctx_with_auth(Some("bearer abc"))).as_deref(),
429            Some("bearer abc")
430        );
431        assert_eq!(fetch_bearer(&ctx_with_auth(None)), None);
432    }
433
434    #[test]
435    fn test_scoped_permissions() {
436        let perms = vec!["res".to_string(), "res2#read".to_string()];
437        assert_eq!(scoped_permissions(&perms, None), perms);
438        assert_eq!(
439            scoped_permissions(&perms, Some("GET")),
440            vec!["res#GET".to_string(), "res2#read, GET".to_string()]
441        );
442    }
443
444    #[test]
445    fn test_encode_uma_body() {
446        let body = encode_uma_body("my-api", &["Default Resource#read".to_string()]);
447        assert!(body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Auma-ticket"));
448        assert!(body.contains("audience=my-api"));
449        assert!(body.contains("response_mode=decision"));
450        // space -> +, '#' -> %23
451        assert!(body.contains("permission=Default+Resource%23read"));
452    }
453
454    /// Only a status Keycloak uses to express an access verdict counts as a
455    /// verdict. Everything else means the node never got one, so it must not
456    /// be laundered into a 403 denial.
457    #[test]
458    fn test_classify_decision_splits_verdicts_from_failures() {
459        assert_eq!(classify_decision(200), Decision::Granted);
460        assert_eq!(classify_decision(401), Decision::Denied);
461        assert_eq!(classify_decision(403), Decision::Denied);
462        for status in [400u16, 404, 500, 502, 503] {
463            assert_eq!(
464                classify_decision(status),
465                Decision::Unexpected,
466                "status {status}"
467            );
468        }
469    }
470
471    #[tokio::test]
472    async fn test_permissive_empty_permissions_allows() {
473        let mut config = HashMap::new();
474        config.insert(
475            "token_endpoint".to_string(),
476            serde_json::json!("https://kc/realms/r/protocol/openid-connect/token"),
477        );
478        config.insert("client_id".to_string(), serde_json::json!("my-api"));
479        config.insert(
480            "policy_enforcement_mode".to_string(),
481            serde_json::json!("PERMISSIVE"),
482        );
483        let plugin = AuthzKeycloakPlugin::from_config(&config, &PluginResources::empty()).unwrap();
484        assert!(plugin
485            .execute(ctx_with_auth(Some("Bearer x")))
486            .await
487            .is_ok());
488    }
489
490    #[tokio::test]
491    async fn test_enforcing_empty_permissions_denies() {
492        let mut config = HashMap::new();
493        config.insert(
494            "token_endpoint".to_string(),
495            serde_json::json!("https://kc/realms/r/protocol/openid-connect/token"),
496        );
497        config.insert("client_id".to_string(), serde_json::json!("my-api"));
498        let plugin = AuthzKeycloakPlugin::from_config(&config, &PluginResources::empty()).unwrap();
499        let out = plugin
500            .execute(ctx_with_auth(Some("Bearer x")))
501            .await
502            .unwrap();
503        assert_eq!(out.port, Some("denied"));
504        assert_eq!(out.context.response.status_code, 403);
505    }
506
507    #[tokio::test]
508    async fn test_missing_bearer_denies() {
509        let mut config = HashMap::new();
510        config.insert(
511            "token_endpoint".to_string(),
512            serde_json::json!("https://kc/realms/r/protocol/openid-connect/token"),
513        );
514        config.insert("client_id".to_string(), serde_json::json!("my-api"));
515        config.insert(
516            "permissions".to_string(),
517            serde_json::json!(["Default Resource#read"]),
518        );
519        let plugin = AuthzKeycloakPlugin::from_config(&config, &PluginResources::empty()).unwrap();
520        let out = plugin.execute(ctx_with_auth(None)).await.unwrap();
521        assert_eq!(out.port, Some("denied"));
522        assert_eq!(out.context.response.status_code, 403);
523    }
524
525    /// Regression: before the port split, a Keycloak callout failure (nothing
526    /// listening on the token endpoint) was folded into the same denial as an
527    /// actual permission refusal. It is a genuine infra failure and must stay
528    /// on `Err`.
529    #[tokio::test]
530    async fn test_token_endpoint_unreachable_stays_on_error_port() {
531        let mut config = HashMap::new();
532        config.insert(
533            "token_endpoint".to_string(),
534            serde_json::json!("http://127.0.0.1:1/token"),
535        );
536        config.insert("client_id".to_string(), serde_json::json!("my-api"));
537        config.insert(
538            "permissions".to_string(),
539            serde_json::json!(["Default Resource#read"]),
540        );
541        config.insert("timeout".to_string(), serde_json::json!(200));
542        let plugin = AuthzKeycloakPlugin::from_config(&config, &PluginResources::empty()).unwrap();
543        let err = plugin
544            .execute(ctx_with_auth(Some("Bearer x")))
545            .await
546            .unwrap_err();
547        crate::plugins::util::provider_error::testing::assert_provider_error(
548            &err,
549            "AUTHZ_KEYCLOAK_ERROR",
550        );
551    }
552
553    /// Minimal one-shot HTTP server that answers any request with a fixed
554    /// status line and no body. Returns its port.
555    async fn spawn_status_server(status_line: &'static str) -> u16 {
556        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
557        let port = listener.local_addr().unwrap().port();
558        tokio::spawn(async move {
559            if let Ok((mut stream, _)) = listener.accept().await {
560                use tokio::io::{AsyncReadExt, AsyncWriteExt};
561                let mut buf = [0u8; 4096];
562                let _ = stream.read(&mut buf).await;
563                let _ = stream
564                    .write_all(
565                        format!("HTTP/1.1 {status_line}\r\ncontent-length: 0\r\n\r\n").as_bytes(),
566                    )
567                    .await;
568                let _ = stream.shutdown().await;
569            }
570        });
571        port
572    }
573
574    fn enforcing_cfg(port: u16) -> HashMap<String, serde_json::Value> {
575        let mut config = HashMap::new();
576        config.insert(
577            "token_endpoint".to_string(),
578            serde_json::json!(format!("http://127.0.0.1:{port}/token")),
579        );
580        config.insert("client_id".to_string(), serde_json::json!("my-api"));
581        config.insert(
582            "permissions".to_string(),
583            serde_json::json!(["Default Resource#read"]),
584        );
585        config.insert("timeout".to_string(), serde_json::json!(2000));
586        config
587    }
588
589    /// Keycloak evaluated the request and refused it (`403 access_denied`):
590    /// a deliberate decision → the `denied` port.
591    #[tokio::test]
592    async fn test_keycloak_403_decision_is_denied() {
593        let port = spawn_status_server("403 Forbidden").await;
594        let plugin =
595            AuthzKeycloakPlugin::from_config(&enforcing_cfg(port), &PluginResources::empty())
596                .unwrap();
597        let out = plugin
598            .execute(ctx_with_auth(Some("Bearer x")))
599            .await
600            .unwrap();
601        assert_eq!(out.port, Some("denied"));
602        assert_eq!(out.context.response.status_code, 403);
603    }
604
605    /// Regression: a `500` (or a `404` from a misconfigured token endpoint)
606    /// used to be laundered into the same 403 denial as a real refusal, hiding
607    /// a broken deployment. It must exit on `error`.
608    #[tokio::test]
609    async fn test_keycloak_5xx_is_error_port_not_denied() {
610        let port = spawn_status_server("500 Internal Server Error").await;
611        let plugin =
612            AuthzKeycloakPlugin::from_config(&enforcing_cfg(port), &PluginResources::empty())
613                .unwrap();
614        let err = plugin
615            .execute(ctx_with_auth(Some("Bearer x")))
616            .await
617            .unwrap_err();
618        crate::plugins::util::provider_error::testing::assert_provider_error(
619            &err,
620            "AUTHZ_KEYCLOAK_ERROR",
621        );
622        assert!(
623            err.error.message.contains("unexpected status 500"),
624            "{}",
625            err.error.message
626        );
627    }
628
629    /// A `404` from a wrong `token_endpoint` path is the same class of problem.
630    #[tokio::test]
631    async fn test_keycloak_404_is_error_port_not_denied() {
632        let port = spawn_status_server("404 Not Found").await;
633        let plugin =
634            AuthzKeycloakPlugin::from_config(&enforcing_cfg(port), &PluginResources::empty())
635                .unwrap();
636        let err = plugin
637            .execute(ctx_with_auth(Some("Bearer x")))
638            .await
639            .unwrap_err();
640        crate::plugins::util::provider_error::testing::assert_provider_error(
641            &err,
642            "AUTHZ_KEYCLOAK_ERROR",
643        );
644    }
645
646    #[test]
647    fn test_requires_token_endpoint_and_client_id() {
648        assert!(
649            AuthzKeycloakPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err()
650        );
651        let mut config = HashMap::new();
652        config.insert(
653            "token_endpoint".to_string(),
654            serde_json::json!("https://kc/token"),
655        );
656        assert!(AuthzKeycloakPlugin::from_config(&config, &PluginResources::empty()).is_err());
657    }
658}