1use 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
50pub struct AuthzKeycloakPlugin {
52 token_endpoint: String,
54 client_id: String,
56 permissions: Vec<String>,
58 enforcing: bool,
61 http_method_as_scope: bool,
63 ssl_verify: bool,
65 timeout: Duration,
67 outbound: Arc<OutboundClient>,
69}
70
71impl AuthzKeycloakPlugin {
72 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 fn deny(ctx: Context, message: impl Into<String>) -> PluginResult {
175 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 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
205fn 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
225fn 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
243fn 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
261fn 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#[derive(Debug, PartialEq, Eq)]
279enum Decision {
280 Granted,
282 Denied,
286 Unexpected,
291}
292
293fn 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 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 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 assert_eq!(
423 fetch_bearer(&ctx_with_auth(Some("abc"))).as_deref(),
424 Some("Bearer abc")
425 );
426 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 assert!(body.contains("permission=Default+Resource%23read"));
452 }
453
454 #[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 #[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 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 #[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 #[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 #[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}