Skip to main content

camel_component_keycloak/
lib.rs

1//! Keycloak integration for rust-camel.
2//!
3//! Provides Keycloak-specific claim presets, realm URL construction,
4//! role extraction, and Admin API producer (Phase 7).
5//! Event consumer will be added in Phase 8.
6
7pub 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        ); // allow-secret
87        url
88    }
89
90    pub fn jwks_uri(&self) -> String {
91        format!("{}/protocol/openid-connect/certs", self.realm_url()) // allow-secret
92    }
93
94    pub fn token_endpoint(&self) -> String {
95        format!("{}/protocol/openid-connect/token", self.realm_url()) // allow-secret
96    }
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}