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; anything else denies it.
10//!
11//! ## Implemented subset
12//!
13//! - Static, pre-configured `permissions` (with optional `http_method_as_scope`).
14//! - `policy_enforcement_mode` (`ENFORCING` / `PERMISSIVE`) for the empty-permission case.
15//! - `ssl_verify` and `timeout`.
16//!
17//! ## Deliberately NOT ported (documented deviations)
18//!
19//! - **Discovery** (`discovery` URL): configure `token_endpoint` directly.
20//! - **`lazy_load_paths`** / resource-registration lookups and the
21//!   service-account (`client_credentials`) token dance — no dynamic resource
22//!   resolution.
23//! - **`password_grant_token_generation_incoming_uri`** token minting.
24//! - Response/token caching and `access_denied_redirect_uri` redirects.
25//!
26//! All denials and callout errors map to `403` (code `AUTHZ_KEYCLOAK_DENIED`)
27//! routed through the node's **error** port.
28
29use async_trait::async_trait;
30use bytes::Bytes;
31use std::collections::HashMap;
32use std::sync::Arc;
33use std::time::Duration;
34
35use crate::context::{Context, GatewayError};
36use crate::outbound::{OutboundClient, OutboundError, OutboundRequest};
37use crate::plugins::resources::PluginResources;
38use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
39
40const UMA_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:uma-ticket";
41
42/// Performs a Keycloak UMA permission check per request.
43pub struct AuthzKeycloakPlugin {
44    /// Keycloak token endpoint (`.../protocol/openid-connect/token`).
45    token_endpoint: String,
46    /// OAuth client id, sent as the UMA `audience`.
47    client_id: String,
48    /// Statically configured permissions (`resource` or `resource#scope`).
49    permissions: Vec<String>,
50    /// `ENFORCING` (default) denies when no permission is configured;
51    /// `PERMISSIVE` allows.
52    enforcing: bool,
53    /// When true, the request method is appended as the permission scope.
54    http_method_as_scope: bool,
55    /// TLS certificate verification for the callout.
56    ssl_verify: bool,
57    /// Whole-call timeout for the callout.
58    timeout: Duration,
59    /// Shared pooled outbound HTTP client.
60    outbound: Arc<OutboundClient>,
61}
62
63impl AuthzKeycloakPlugin {
64    /// Builds the plugin from node config.
65    ///
66    /// Accepted keys:
67    /// - `token_endpoint` (string, **required**): Keycloak token endpoint URL.
68    ///   (APISIX's `discovery` auto-resolution is not supported.)
69    /// - `client_id` (string, **required**): OAuth client id, sent as the UMA
70    ///   `audience`.
71    /// - `permissions` (array of strings, default `[]`): requested permissions,
72    ///   each `resource` or `resource#scope`.
73    /// - `policy_enforcement_mode` (string, default `"ENFORCING"`): `ENFORCING`
74    ///   denies when `permissions` is empty; `PERMISSIVE` allows without a callout.
75    /// - `http_method_as_scope` (bool, default `false`): append the request
76    ///   method as the scope of each permission.
77    /// - `ssl_verify` (bool, default `true`): verify the endpoint's TLS certificate.
78    /// - `timeout` (integer ms, default `3000`): callout timeout.
79    ///
80    /// ```yaml
81    /// - id: authz
82    ///   type: authz-keycloak
83    ///   config:
84    ///     token_endpoint: https://kc.example.com/realms/myrealm/protocol/openid-connect/token
85    ///     client_id: my-api
86    ///     permissions: ["Default Resource#read"]
87    ///     policy_enforcement_mode: ENFORCING
88    ///     ssl_verify: true
89    ///     timeout: 3000
90    /// ```
91    pub fn from_config(
92        config: &HashMap<String, serde_json::Value>,
93        resources: &Arc<PluginResources>,
94    ) -> Result<Self, String> {
95        let token_endpoint = config
96            .get("token_endpoint")
97            .and_then(|v| v.as_str())
98            .filter(|s| !s.is_empty())
99            .ok_or_else(|| {
100                "authz-keycloak requires 'token_endpoint' (discovery is not supported)".to_string()
101            })?
102            .to_string();
103
104        let client_id = config
105            .get("client_id")
106            .and_then(|v| v.as_str())
107            .filter(|s| !s.is_empty())
108            .ok_or_else(|| "authz-keycloak requires 'client_id'".to_string())?
109            .to_string();
110
111        let permissions: Vec<String> = config
112            .get("permissions")
113            .and_then(|v| v.as_array())
114            .map(|seq| {
115                seq.iter()
116                    .filter_map(|v| v.as_str().map(String::from))
117                    .collect()
118            })
119            .unwrap_or_default();
120
121        let mode = config
122            .get("policy_enforcement_mode")
123            .and_then(|v| v.as_str())
124            .unwrap_or("ENFORCING");
125        let enforcing = match mode {
126            "ENFORCING" => true,
127            "PERMISSIVE" => false,
128            other => {
129                return Err(format!(
130                    "authz-keycloak: invalid policy_enforcement_mode '{other}' \
131                     (expected ENFORCING or PERMISSIVE)"
132                ))
133            }
134        };
135
136        let http_method_as_scope = config
137            .get("http_method_as_scope")
138            .and_then(|v| v.as_bool())
139            .unwrap_or(false);
140
141        let ssl_verify = config
142            .get("ssl_verify")
143            .and_then(|v| v.as_bool())
144            .unwrap_or(true);
145
146        let timeout_ms = config
147            .get("timeout")
148            .and_then(|v| v.as_u64())
149            .unwrap_or(3000);
150
151        Ok(Self {
152            token_endpoint,
153            client_id,
154            permissions,
155            enforcing,
156            http_method_as_scope,
157            ssl_verify,
158            timeout: Duration::from_millis(timeout_ms),
159            outbound: resources.outbound.clone(),
160        })
161    }
162
163    /// Builds the 403 denial carrying the context.
164    fn deny(ctx: Context, message: impl Into<String>) -> PluginResult {
165        let mut ctx = ctx;
166        ctx.response.status_code = 403;
167        ctx.response.body =
168            Bytes::from(r#"{"error":"access_denied","error_description":"not_authorized"}"#);
169        ctx.response.headers.insert(
170            "content-type".to_string(),
171            vec!["application/json".to_string()],
172        );
173        Err(PluginExecutionError {
174            context: ctx,
175            error: GatewayError {
176                node_id: String::new(),
177                code: "AUTHZ_KEYCLOAK_DENIED".to_string(),
178                message: message.into(),
179                metadata: HashMap::new(),
180            },
181        })
182    }
183}
184
185/// Extracts the bearer token from the `Authorization` header, normalizing to a
186/// `Bearer `-prefixed value (mirroring APISIX's `fetch_jwt_token`).
187fn fetch_bearer(ctx: &Context) -> Option<String> {
188    let raw = ctx
189        .request
190        .headers
191        .get("authorization")
192        .and_then(|v| v.first())?
193        .trim();
194    if raw.is_empty() {
195        return None;
196    }
197    let lower = raw.to_ascii_lowercase();
198    if lower.starts_with("bearer ") {
199        Some(raw.to_string())
200    } else {
201        Some(format!("Bearer {raw}"))
202    }
203}
204
205/// Applies `http_method_as_scope`: appends `#<method>` to each permission, or
206/// `, <method>` when a scope is already present (matching APISIX's logic).
207fn scoped_permissions(permissions: &[String], method: Option<&str>) -> Vec<String> {
208    match method {
209        None => permissions.to_vec(),
210        Some(m) => permissions
211            .iter()
212            .map(|p| {
213                if p.contains('#') {
214                    format!("{p}, {m}")
215                } else {
216                    format!("{p}#{m}")
217                }
218            })
219            .collect(),
220    }
221}
222
223/// Encodes the UMA permission-check request body as
224/// `application/x-www-form-urlencoded`, repeating `permission` per entry.
225fn encode_uma_body(client_id: &str, permissions: &[String]) -> String {
226    let mut pairs: Vec<(String, String)> = vec![
227        ("grant_type".to_string(), UMA_GRANT_TYPE.to_string()),
228        ("audience".to_string(), client_id.to_string()),
229        ("response_mode".to_string(), "decision".to_string()),
230    ];
231    for p in permissions {
232        pairs.push(("permission".to_string(), p.clone()));
233    }
234    pairs
235        .iter()
236        .map(|(k, v)| format!("{}={}", form_encode(k), form_encode(v)))
237        .collect::<Vec<_>>()
238        .join("&")
239}
240
241/// Percent-encodes a value for `application/x-www-form-urlencoded` bodies
242/// (unreserved characters pass through; space becomes `+`).
243fn form_encode(s: &str) -> String {
244    let mut out = String::with_capacity(s.len());
245    for &b in s.as_bytes() {
246        match b {
247            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
248                out.push(b as char)
249            }
250            b' ' => out.push('+'),
251            _ => out.push_str(&format!("%{b:02X}")),
252        }
253    }
254    out
255}
256
257/// Maps a Keycloak UMA response status to an allow/deny decision: only `200`
258/// (the decision endpoint's "granted" response) allows.
259fn decision_allows(status: u16) -> bool {
260    status == 200
261}
262
263#[async_trait]
264impl Plugin for AuthzKeycloakPlugin {
265    fn plugin_type(&self) -> &str {
266        "authz-keycloak"
267    }
268
269    async fn execute(
270        &self,
271        ctx: Context,
272        _named_inputs: &HashMap<String, serde_json::Value>,
273    ) -> PluginResult {
274        // Empty permissions: deny under ENFORCING, allow under PERMISSIVE.
275        if self.permissions.is_empty() {
276            return if self.enforcing {
277                Self::deny(ctx, "no permissions configured (ENFORCING)")
278            } else {
279                Ok(PluginOutput {
280                    context: ctx,
281                    named_outputs: HashMap::new(),
282                })
283            };
284        }
285
286        let token = match fetch_bearer(&ctx) {
287            Some(t) => t,
288            None => return Self::deny(ctx, "missing bearer token"),
289        };
290
291        let method_scope = if self.http_method_as_scope {
292            Some(ctx.request.method.as_str())
293        } else {
294            None
295        };
296        let permissions = scoped_permissions(&self.permissions, method_scope);
297        let body = encode_uma_body(&self.client_id, &permissions);
298
299        let request = OutboundRequest {
300            method: http::Method::POST,
301            url: self.token_endpoint.clone(),
302            headers: vec![
303                (
304                    "content-type".to_string(),
305                    "application/x-www-form-urlencoded".to_string(),
306                ),
307                ("authorization".to_string(), token),
308            ],
309            body: Bytes::from(body),
310            timeout: self.timeout,
311            ssl_verify: self.ssl_verify,
312            tls: None,
313        };
314
315        match self.outbound.request(request).await {
316            Ok(resp) if decision_allows(resp.status) => Ok(PluginOutput {
317                context: ctx,
318                named_outputs: HashMap::new(),
319            }),
320            Ok(resp) => Self::deny(
321                ctx,
322                format!("Keycloak denied permission (status {})", resp.status),
323            ),
324            Err(e) => {
325                let detail = match &e {
326                    OutboundError::Timeout(d) => format!("Keycloak request timed out after {d:?}"),
327                    OutboundError::InvalidRequest(m) => format!("invalid Keycloak request: {m}"),
328                    OutboundError::Transport(m) => format!("Keycloak request failed: {m}"),
329                };
330                Self::deny(ctx, detail)
331            }
332        }
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
340
341    fn ctx_with_auth(auth: Option<&str>) -> Context {
342        let mut headers = HashMap::new();
343        if let Some(a) = auth {
344            headers.insert("authorization".to_string(), vec![a.to_string()]);
345        }
346        Context {
347            request: GatewayRequest {
348                method: "GET".to_string(),
349                path: "/data".to_string(),
350                host: "h".to_string(),
351                scheme: "http".to_string(),
352                headers,
353                query_params: HashMap::new(),
354                body: Bytes::new(),
355                remote_addr: "1.2.3.4:5".to_string(),
356                protocol: Protocol::Http1,
357            },
358            response: GatewayResponse {
359                status_code: 0,
360                headers: HashMap::new(),
361                body: Bytes::new(),
362            },
363            message: HashMap::new(),
364            errors: Vec::new(),
365        }
366    }
367
368    #[test]
369    fn test_fetch_bearer_normalizes_prefix() {
370        assert_eq!(
371            fetch_bearer(&ctx_with_auth(Some("Bearer abc"))).as_deref(),
372            Some("Bearer abc")
373        );
374        // missing prefix gets one
375        assert_eq!(
376            fetch_bearer(&ctx_with_auth(Some("abc"))).as_deref(),
377            Some("Bearer abc")
378        );
379        // lowercase prefix preserved
380        assert_eq!(
381            fetch_bearer(&ctx_with_auth(Some("bearer abc"))).as_deref(),
382            Some("bearer abc")
383        );
384        assert_eq!(fetch_bearer(&ctx_with_auth(None)), None);
385    }
386
387    #[test]
388    fn test_scoped_permissions() {
389        let perms = vec!["res".to_string(), "res2#read".to_string()];
390        assert_eq!(scoped_permissions(&perms, None), perms);
391        assert_eq!(
392            scoped_permissions(&perms, Some("GET")),
393            vec!["res#GET".to_string(), "res2#read, GET".to_string()]
394        );
395    }
396
397    #[test]
398    fn test_encode_uma_body() {
399        let body = encode_uma_body("my-api", &["Default Resource#read".to_string()]);
400        assert!(body.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Auma-ticket"));
401        assert!(body.contains("audience=my-api"));
402        assert!(body.contains("response_mode=decision"));
403        // space -> +, '#' -> %23
404        assert!(body.contains("permission=Default+Resource%23read"));
405    }
406
407    #[test]
408    fn test_decision_allows() {
409        assert!(decision_allows(200));
410        assert!(!decision_allows(401));
411        assert!(!decision_allows(403));
412        assert!(!decision_allows(500));
413    }
414
415    #[tokio::test]
416    async fn test_permissive_empty_permissions_allows() {
417        let mut config = HashMap::new();
418        config.insert(
419            "token_endpoint".to_string(),
420            serde_json::json!("https://kc/realms/r/protocol/openid-connect/token"),
421        );
422        config.insert("client_id".to_string(), serde_json::json!("my-api"));
423        config.insert(
424            "policy_enforcement_mode".to_string(),
425            serde_json::json!("PERMISSIVE"),
426        );
427        let plugin = AuthzKeycloakPlugin::from_config(&config, &PluginResources::empty()).unwrap();
428        assert!(plugin
429            .execute(ctx_with_auth(Some("Bearer x")), &HashMap::new())
430            .await
431            .is_ok());
432    }
433
434    #[tokio::test]
435    async fn test_enforcing_empty_permissions_denies() {
436        let mut config = HashMap::new();
437        config.insert(
438            "token_endpoint".to_string(),
439            serde_json::json!("https://kc/realms/r/protocol/openid-connect/token"),
440        );
441        config.insert("client_id".to_string(), serde_json::json!("my-api"));
442        let plugin = AuthzKeycloakPlugin::from_config(&config, &PluginResources::empty()).unwrap();
443        let err = plugin
444            .execute(ctx_with_auth(Some("Bearer x")), &HashMap::new())
445            .await
446            .unwrap_err();
447        assert_eq!(err.error.code, "AUTHZ_KEYCLOAK_DENIED");
448        assert_eq!(err.context.response.status_code, 403);
449    }
450
451    #[test]
452    fn test_requires_token_endpoint_and_client_id() {
453        assert!(
454            AuthzKeycloakPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err()
455        );
456        let mut config = HashMap::new();
457        config.insert(
458            "token_endpoint".to_string(),
459            serde_json::json!("https://kc/token"),
460        );
461        assert!(AuthzKeycloakPlugin::from_config(&config, &PluginResources::empty()).is_err());
462    }
463}