camel_component_keycloak/
lib.rs1pub mod admin_endpoint_config;
8pub mod admin_operation;
9pub mod admin_types;
10pub mod event_types;
11pub mod events_endpoint_config;
12pub mod keycloak_consumer;
13pub mod keycloak_endpoint;
14pub mod keycloak_producer;
15pub mod uma;
16
17use std::fmt;
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use camel_api::CamelError;
22use camel_auth::claims::ClaimPaths;
23use camel_auth::oauth2::{ClientCredentialsProvider, TokenProvider};
24use camel_auth::permission::PermissionEvaluator;
25use camel_auth::types::AuthError;
26use camel_component_api::{Component, ComponentContext, Endpoint};
27use serde::{Deserialize, Serialize};
28
29#[derive(Clone, Deserialize, Serialize)]
30pub struct KeycloakRealmConfig {
31 server_url: String,
32 realm: String,
33 client_id: String,
34 #[serde(skip_serializing)]
35 client_secret: Option<String>,
36}
37
38impl fmt::Debug for KeycloakRealmConfig {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 let secret_display = if self.client_secret.is_some() {
41 "REDACTED"
42 } else {
43 "None"
44 };
45 f.debug_struct("KeycloakRealmConfig")
46 .field("server_url", &self.server_url)
47 .field("realm", &self.realm)
48 .field("client_id", &self.client_id)
49 .field("client_secret", &secret_display)
50 .finish()
51 }
52}
53
54fn escape_json_pointer(s: &str) -> String {
55 let mut out = String::with_capacity(s.len());
56 for c in s.chars() {
57 match c {
58 '~' => out.push_str("~0"),
59 '/' => out.push_str("~1"),
60 _ => out.push(c),
61 }
62 }
63 out
64}
65
66impl KeycloakRealmConfig {
67 pub fn new(server_url: String, realm: String, client_id: String) -> Self {
68 Self {
69 server_url,
70 realm,
71 client_id,
72 client_secret: None,
73 }
74 }
75
76 pub fn with_client_secret(mut self, secret: String) -> Self {
77 self.client_secret = Some(secret);
78 self
79 }
80
81 pub fn realm_url(&self) -> String {
82 let url = format!(
83 "{}/realms/{}",
84 self.server_url.trim_end_matches('/'),
85 self.realm
86 ); url
88 }
89
90 pub fn jwks_uri(&self) -> String {
91 format!("{}/protocol/openid-connect/certs", self.realm_url()) }
93
94 pub fn token_endpoint(&self) -> String {
95 format!("{}/protocol/openid-connect/token", self.realm_url()) }
97
98 pub fn introspection_endpoint(&self) -> String {
99 self.realm_url() + "/protocol/openid-connect/token/introspect"
100 }
101
102 pub fn admin_url(&self) -> String {
103 format!(
104 "{}/admin/realms/{}",
105 self.server_url.trim_end_matches('/'),
106 self.realm
107 )
108 }
109
110 pub fn server_url(&self) -> &str {
111 &self.server_url
112 }
113
114 pub fn realm(&self) -> &str {
115 &self.realm
116 }
117
118 pub fn client_id(&self) -> &str {
119 &self.client_id
120 }
121
122 pub fn client_secret(&self) -> Option<&str> {
123 self.client_secret.as_deref()
124 }
125
126 pub fn introspection_authenticator(
127 &self,
128 options: camel_auth::IntrospectionCacheOptions,
129 ) -> Result<camel_auth::IntrospectionAuthenticator, CamelError> {
130 use camel_auth::IntrospectionAuthenticator;
131 use camel_auth::claims::{ClaimsMapper, JsonPointerClaimsMapper};
132
133 let secret = self
134 .client_secret()
135 .ok_or_else(|| CamelError::Config("introspection requires client_secret".into()))?;
136 let introspector = camel_auth::CachingTokenIntrospector::new(
137 self.introspection_endpoint(),
138 self.client_id().to_string(),
139 secret.to_string(),
140 options,
141 )
142 .map_err(|e| CamelError::Config(e.to_string()))?;
143 let mapper: Arc<dyn ClaimsMapper> = Arc::new(JsonPointerClaimsMapper::new(
144 keycloak_claim_paths(self.client_id()),
145 ));
146 Ok(IntrospectionAuthenticator::new(
147 Arc::new(introspector),
148 mapper,
149 ))
150 }
151
152 pub fn uma_evaluator(&self) -> Result<Arc<dyn PermissionEvaluator>, AuthError> {
153 let secret = self
154 .client_secret
155 .as_ref()
156 .ok_or_else(|| AuthError::ConfigError("client_secret required for UMA".into()))?;
157 let evaluator = KeycloakUmaEvaluator::new(
158 self.server_url.clone(),
159 self.realm.clone(),
160 self.client_id.clone(),
161 secret.clone(),
162 )?;
163 Ok(Arc::new(evaluator))
164 }
165}
166
167impl fmt::Display for KeycloakRealmConfig {
168 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169 write!(
170 f,
171 "KeycloakRealmConfig(server={}, realm={}, client={})",
172 self.server_url, self.realm, self.client_id
173 )
174 }
175}
176
177pub fn keycloak_claim_paths(client_id: &str) -> ClaimPaths {
178 ClaimPaths {
179 subject: "/sub".into(),
180 roles: vec![
181 "/realm_access/roles".into(),
182 format!("/resource_access/{}/roles", escape_json_pointer(client_id)),
183 ],
184 scopes: Some("/scope".into()),
185 }
186}
187
188pub use admin_endpoint_config::AdminEndpointConfig;
189pub use admin_operation::AdminOperation;
190pub use events_endpoint_config::EventsEndpointConfig;
191pub use keycloak_consumer::KeycloakEventConsumer;
192pub use keycloak_endpoint::{KeycloakEndpoint, KeycloakEndpointConfig, KeycloakEndpointKind};
193pub use keycloak_producer::KeycloakAdminProducer;
194pub use uma::KeycloakUmaEvaluator;
195
196pub struct KeycloakComponent {
197 server_url: String,
198 token_provider: Arc<dyn TokenProvider>,
199 http: reqwest::Client,
200}
201
202impl KeycloakComponent {
203 pub fn new(config: &KeycloakRealmConfig) -> Result<Self, CamelError> {
204 let secret = config.client_secret().ok_or_else(|| {
205 CamelError::EndpointCreationFailed("keycloak component requires client_secret".into())
206 })?;
207 let token_provider = Arc::new(ClientCredentialsProvider::new(
208 config.token_endpoint(),
209 config.client_id().to_string(),
210 secret.to_string(),
211 None,
212 None,
213 ));
214 Ok(Self {
215 server_url: config.server_url().to_string(),
216 token_provider,
217 http: reqwest::Client::new(),
218 })
219 }
220}
221
222#[async_trait]
223impl Component for KeycloakComponent {
224 fn scheme(&self) -> &str {
225 "keycloak"
226 }
227
228 fn create_endpoint(
229 &self,
230 uri: &str,
231 _ctx: &dyn ComponentContext,
232 ) -> Result<Box<dyn Endpoint>, CamelError> {
233 let config = KeycloakEndpointConfig::from_uri(
234 uri,
235 &self.server_url,
236 Arc::clone(&self.token_provider),
237 self.http.clone(),
238 )?;
239 Ok(Box::new(KeycloakEndpoint::new(uri.to_string(), config)))
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use camel_auth::IntrospectionCacheOptions;
247 use camel_auth::claims::{ClaimsMapper, JsonPointerClaimsMapper};
248 use camel_component_api::NoOpComponentContext;
249 use serde_json::json;
250
251 #[test]
252 fn keycloak_claim_paths_contains_both_role_paths() {
253 let paths = keycloak_claim_paths("my-client");
254 assert_eq!(paths.subject, "/sub");
255 assert!(paths.roles.contains(&"/realm_access/roles".into()));
256 assert!(
257 paths
258 .roles
259 .contains(&"/resource_access/my-client/roles".into())
260 );
261 assert_eq!(paths.scopes, Some("/scope".into()));
262 }
263
264 #[test]
265 fn claim_paths_escapes_client_id() {
266 let paths = keycloak_claim_paths("my/client");
267 assert!(
268 paths
269 .roles
270 .iter()
271 .any(|p| p == "/resource_access/my~1client/roles")
272 );
273 }
274
275 #[test]
276 fn claim_paths_escapes_tilde_in_client_id() {
277 let paths = keycloak_claim_paths("my~client");
278 assert!(
279 paths
280 .roles
281 .iter()
282 .any(|p| p == "/resource_access/my~0client/roles")
283 );
284 }
285
286 #[test]
287 fn claim_paths_produces_valid_principal_via_mapper() {
288 let paths = keycloak_claim_paths("my-client");
289 let mapper = JsonPointerClaimsMapper::new(paths);
290 let claims = json!({
291 "sub": "user-1",
292 "iss": "https://kc.example.com/realms/test",
293 "aud": "my-api",
294 "realm_access": { "roles": ["admin"] },
295 "resource_access": {
296 "my-client": { "roles": ["client-role"] }
297 },
298 "scope": "read write",
299 });
300 let principal = mapper.to_principal(&claims).unwrap();
301 assert_eq!(principal.subject, "user-1");
302 assert!(principal.has_role("admin"));
303 assert!(principal.has_role("client-role"));
304 assert_eq!(principal.scopes, vec!["read", "write"]);
305 }
306
307 #[test]
308 fn realm_url() {
309 let config = KeycloakRealmConfig::new(
310 "http://localhost:8080".into(),
311 "my-realm".into(),
312 "my-client".into(),
313 );
314 assert_eq!(config.realm_url(), "http://localhost:8080/realms/my-realm");
315 }
316
317 #[test]
318 fn jwks_uri() {
319 let config = KeycloakRealmConfig::new(
320 "http://localhost:8080".into(),
321 "my-realm".into(),
322 "my-client".into(),
323 );
324 assert_eq!(
325 config.jwks_uri(),
326 "http://localhost:8080/realms/my-realm/protocol/openid-connect/certs"
327 );
328 }
329
330 #[test]
331 fn token_endpoint() {
332 let config = KeycloakRealmConfig::new(
333 "http://localhost:8080".into(),
334 "my-realm".into(),
335 "my-client".into(),
336 );
337 assert_eq!(
338 config.token_endpoint(),
339 "http://localhost:8080/realms/my-realm/protocol/openid-connect/token"
340 );
341 }
342
343 #[test]
344 fn trailing_slash_handling() {
345 let config = KeycloakRealmConfig::new(
346 "http://localhost:8080/".into(),
347 "test".into(),
348 "client".into(),
349 );
350 assert_eq!(config.realm_url(), "http://localhost:8080/realms/test");
351 }
352
353 #[test]
354 fn debug_redacts_client_secret() {
355 let config = KeycloakRealmConfig::new(
356 "https://kc.example.com".into(),
357 "myrealm".into(),
358 "myclient".into(),
359 )
360 .with_client_secret("super-secret".into());
361 let debug_str = format!("{config:?}");
362 assert!(!debug_str.contains("super-secret"));
363 assert!(debug_str.contains("REDACTED"));
364 }
365
366 #[test]
367 fn empty_client_id_produces_malformed_resource_path() {
368 let paths = keycloak_claim_paths("");
369 assert!(
370 paths.roles.iter().any(|p| p == "/resource_access//roles"),
371 "empty client_id should produce /resource_access//roles — caller must validate"
372 );
373 }
374
375 #[test]
376 fn keycloak_config_client_secret_accessor_with_secret() {
377 let config = KeycloakRealmConfig::new(
378 "https://kc.example.com".into(),
379 "myrealm".into(),
380 "myclient".into(),
381 )
382 .with_client_secret("secret-123".into());
383 assert_eq!(config.client_secret(), Some("secret-123"));
384 }
385
386 #[test]
387 fn keycloak_config_client_secret_accessor_without_secret() {
388 let config = KeycloakRealmConfig::new(
389 "https://kc.example.com".into(),
390 "myrealm".into(),
391 "myclient".into(),
392 );
393 assert!(config.client_secret().is_none());
394 }
395
396 #[test]
397 fn keycloak_component_scheme() {
398 let config = KeycloakRealmConfig::new(
399 "https://kc.example.com".into(),
400 "myrealm".into(),
401 "myclient".into(),
402 )
403 .with_client_secret("secret".into());
404 let component = KeycloakComponent::new(&config).unwrap();
405 assert_eq!(component.scheme(), "keycloak");
406 }
407
408 #[test]
409 fn keycloak_component_create_endpoint_valid() {
410 let config = KeycloakRealmConfig::new(
411 "https://kc.example.com".into(),
412 "myrealm".into(),
413 "myclient".into(),
414 )
415 .with_client_secret("secret".into());
416 let component = KeycloakComponent::new(&config).unwrap();
417 let ctx = NoOpComponentContext;
418 let endpoint = component
419 .create_endpoint(
420 "keycloak:admin?operation=getUser&realm=myrealm&userId=user-1",
421 &ctx,
422 )
423 .unwrap();
424 assert_eq!(
425 endpoint.uri(),
426 "keycloak:admin?operation=getUser&realm=myrealm&userId=user-1"
427 );
428 }
429
430 #[test]
431 fn keycloak_component_create_endpoint_invalid() {
432 let config = KeycloakRealmConfig::new(
433 "https://kc.example.com".into(),
434 "myrealm".into(),
435 "myclient".into(),
436 )
437 .with_client_secret("secret".into());
438 let component = KeycloakComponent::new(&config).unwrap();
439 let ctx = NoOpComponentContext;
440 let result = component.create_endpoint("keycloak:badpath", &ctx);
441 assert!(result.is_err());
442 }
443
444 #[test]
445 fn introspection_authenticator_builder_derives_endpoint() {
446 let config = KeycloakRealmConfig::new(
447 "https://kc.example.com".into(),
448 "test-realm".into(),
449 "my-client".into(),
450 )
451 .with_client_secret("secret".into());
452
453 let opts = IntrospectionCacheOptions::default();
454 let result = config.introspection_authenticator(opts);
455 assert!(result.is_ok(), "builder should succeed with client_secret");
456 }
457
458 #[test]
459 fn introspection_authenticator_builder_requires_client_secret() {
460 let config = KeycloakRealmConfig::new(
461 "https://kc.example.com".into(),
462 "test-realm".into(),
463 "my-client".into(),
464 );
465
466 let opts = IntrospectionCacheOptions::default();
467 let result = config.introspection_authenticator(opts);
468 assert!(result.is_err(), "builder should fail without client_secret");
469 let err = result.unwrap_err();
470 match err {
471 CamelError::Config(msg) => assert!(msg.contains("client_secret")),
472 other => panic!("expected Config error, got: {other:?}"),
473 }
474 }
475
476 #[test]
477 fn introspection_authenticator_maps_keycloak_roles() {
478 let config = KeycloakRealmConfig::new(
479 "https://kc.example.com".into(),
480 "test-realm".into(),
481 "svc".into(),
482 )
483 .with_client_secret("s".into());
484
485 let opts = IntrospectionCacheOptions::default();
486 let _auth = config.introspection_authenticator(opts).unwrap();
487
488 let claims = json!({
489 "active": true,
490 "sub": "user-1",
491 "realm_access": {"roles": ["admin"]},
492 "resource_access": {"svc": {"roles": ["svc-role"]}},
493 "scope": "read"
494 });
495
496 let mapper = JsonPointerClaimsMapper::new(keycloak_claim_paths("svc"));
497 let principal = mapper.to_principal(&claims).unwrap();
498 assert!(principal.has_role("admin"));
499 assert!(principal.has_role("svc-role"));
500 assert_eq!(principal.scopes, vec!["read"]);
501 }
502
503 #[test]
504 fn uma_evaluator_builder_returns_evaluator() {
505 let config = KeycloakRealmConfig::new(
506 "https://kc.example.com".into(),
507 "test".into(),
508 "authz-client".into(),
509 )
510 .with_client_secret("secret".into());
511 let evaluator = config.uma_evaluator();
512 assert!(evaluator.is_ok());
513 let eval = evaluator.unwrap();
514 let _arc: Arc<dyn PermissionEvaluator> = eval;
515 }
516
517 #[test]
518 fn uma_evaluator_fails_without_client_secret() {
519 let config = KeycloakRealmConfig::new(
520 "https://kc.example.com".into(),
521 "test".into(),
522 "authz-client".into(),
523 );
524 let result = config.uma_evaluator();
525 assert!(result.is_err());
526 }
527}