featherbit/plugins/native/
authz_keycloak.rs1use 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
42pub struct AuthzKeycloakPlugin {
44 token_endpoint: String,
46 client_id: String,
48 permissions: Vec<String>,
50 enforcing: bool,
53 http_method_as_scope: bool,
55 ssl_verify: bool,
57 timeout: Duration,
59 outbound: Arc<OutboundClient>,
61}
62
63impl AuthzKeycloakPlugin {
64 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 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
185fn 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
205fn 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
223fn 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
241fn 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
257fn 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 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 assert_eq!(
376 fetch_bearer(&ctx_with_auth(Some("abc"))).as_deref(),
377 Some("Bearer abc")
378 );
379 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 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}