pub mod admin_endpoint_config;
pub mod admin_operation;
pub mod admin_types;
pub mod event_types;
pub mod events_endpoint_config;
pub mod keycloak_consumer;
pub mod keycloak_endpoint;
pub mod keycloak_producer;
pub mod uma;
use std::fmt;
use std::sync::Arc;
use async_trait::async_trait;
use camel_api::CamelError;
use camel_auth::claims::ClaimPaths;
use camel_auth::oauth2::{ClientCredentialsProvider, TokenProvider};
use camel_auth::permission::PermissionEvaluator;
use camel_auth::types::AuthError;
use camel_component_api::{Component, ComponentContext, Endpoint};
use serde::{Deserialize, Serialize};
#[derive(Clone, Deserialize, Serialize)]
pub struct KeycloakRealmConfig {
server_url: String,
realm: String,
client_id: String,
#[serde(skip_serializing)]
client_secret: Option<String>,
}
impl fmt::Debug for KeycloakRealmConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let secret_display = if self.client_secret.is_some() {
"REDACTED"
} else {
"None"
};
f.debug_struct("KeycloakRealmConfig")
.field("server_url", &self.server_url)
.field("realm", &self.realm)
.field("client_id", &self.client_id)
.field("client_secret", &secret_display)
.finish()
}
}
fn escape_json_pointer(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'~' => out.push_str("~0"),
'/' => out.push_str("~1"),
_ => out.push(c),
}
}
out
}
impl KeycloakRealmConfig {
pub fn new(server_url: String, realm: String, client_id: String) -> Self {
Self {
server_url,
realm,
client_id,
client_secret: None,
}
}
pub fn with_client_secret(mut self, secret: String) -> Self {
self.client_secret = Some(secret);
self
}
pub fn realm_url(&self) -> String {
let url = format!(
"{}/realms/{}",
self.server_url.trim_end_matches('/'),
self.realm
); url
}
pub fn jwks_uri(&self) -> String {
format!("{}/protocol/openid-connect/certs", self.realm_url()) }
pub fn token_endpoint(&self) -> String {
format!("{}/protocol/openid-connect/token", self.realm_url()) }
pub fn introspection_endpoint(&self) -> String {
self.realm_url() + "/protocol/openid-connect/token/introspect"
}
pub fn admin_url(&self) -> String {
format!(
"{}/admin/realms/{}",
self.server_url.trim_end_matches('/'),
self.realm
)
}
pub fn server_url(&self) -> &str {
&self.server_url
}
pub fn realm(&self) -> &str {
&self.realm
}
pub fn client_id(&self) -> &str {
&self.client_id
}
pub fn client_secret(&self) -> Option<&str> {
self.client_secret.as_deref()
}
pub fn introspection_authenticator(
&self,
options: camel_auth::IntrospectionCacheOptions,
) -> Result<camel_auth::IntrospectionAuthenticator, CamelError> {
use camel_auth::IntrospectionAuthenticator;
use camel_auth::claims::{ClaimsMapper, JsonPointerClaimsMapper};
let secret = self
.client_secret()
.ok_or_else(|| CamelError::Config("introspection requires client_secret".into()))?;
let introspector = camel_auth::CachingTokenIntrospector::new(
self.introspection_endpoint(),
self.client_id().to_string(),
secret.to_string(),
options,
)
.map_err(|e| CamelError::Config(e.to_string()))?;
let mapper: Arc<dyn ClaimsMapper> = Arc::new(JsonPointerClaimsMapper::new(
keycloak_claim_paths(self.client_id()),
));
Ok(IntrospectionAuthenticator::new(
Arc::new(introspector),
mapper,
))
}
pub fn uma_evaluator(&self) -> Result<Arc<dyn PermissionEvaluator>, AuthError> {
let secret = self
.client_secret
.as_ref()
.ok_or_else(|| AuthError::ConfigError("client_secret required for UMA".into()))?;
let evaluator = KeycloakUmaEvaluator::new(
self.server_url.clone(),
self.realm.clone(),
self.client_id.clone(),
secret.clone(),
)?;
Ok(Arc::new(evaluator))
}
}
impl fmt::Display for KeycloakRealmConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"KeycloakRealmConfig(server={}, realm={}, client={})",
self.server_url, self.realm, self.client_id
)
}
}
pub fn keycloak_claim_paths(client_id: &str) -> ClaimPaths {
ClaimPaths {
subject: "/sub".into(),
roles: vec![
"/realm_access/roles".into(),
format!("/resource_access/{}/roles", escape_json_pointer(client_id)),
],
scopes: Some("/scope".into()),
}
}
pub use admin_endpoint_config::AdminEndpointConfig;
pub use admin_operation::AdminOperation;
pub use events_endpoint_config::EventsEndpointConfig;
pub use keycloak_consumer::KeycloakEventConsumer;
pub use keycloak_endpoint::{KeycloakEndpoint, KeycloakEndpointConfig, KeycloakEndpointKind};
pub use keycloak_producer::KeycloakAdminProducer;
pub use uma::KeycloakUmaEvaluator;
pub struct KeycloakComponent {
server_url: String,
token_provider: Arc<dyn TokenProvider>,
http: reqwest::Client,
}
impl KeycloakComponent {
pub fn new(config: &KeycloakRealmConfig) -> Result<Self, CamelError> {
let secret = config.client_secret().ok_or_else(|| {
CamelError::EndpointCreationFailed("keycloak component requires client_secret".into())
})?;
let token_provider = Arc::new(ClientCredentialsProvider::new(
config.token_endpoint(),
config.client_id().to_string(),
secret.to_string(),
None,
None,
));
Ok(Self {
server_url: config.server_url().to_string(),
token_provider,
http: reqwest::Client::new(),
})
}
}
#[async_trait]
impl Component for KeycloakComponent {
fn scheme(&self) -> &str {
"keycloak"
}
fn create_endpoint(
&self,
uri: &str,
_ctx: &dyn ComponentContext,
) -> Result<Box<dyn Endpoint>, CamelError> {
let config = KeycloakEndpointConfig::from_uri(
uri,
&self.server_url,
Arc::clone(&self.token_provider),
self.http.clone(),
)?;
Ok(Box::new(KeycloakEndpoint::new(uri.to_string(), config)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use camel_auth::IntrospectionCacheOptions;
use camel_auth::claims::{ClaimsMapper, JsonPointerClaimsMapper};
use camel_component_api::NoOpComponentContext;
use serde_json::json;
#[test]
fn keycloak_claim_paths_contains_both_role_paths() {
let paths = keycloak_claim_paths("my-client");
assert_eq!(paths.subject, "/sub");
assert!(paths.roles.contains(&"/realm_access/roles".into()));
assert!(
paths
.roles
.contains(&"/resource_access/my-client/roles".into())
);
assert_eq!(paths.scopes, Some("/scope".into()));
}
#[test]
fn claim_paths_escapes_client_id() {
let paths = keycloak_claim_paths("my/client");
assert!(
paths
.roles
.iter()
.any(|p| p == "/resource_access/my~1client/roles")
);
}
#[test]
fn claim_paths_escapes_tilde_in_client_id() {
let paths = keycloak_claim_paths("my~client");
assert!(
paths
.roles
.iter()
.any(|p| p == "/resource_access/my~0client/roles")
);
}
#[test]
fn claim_paths_produces_valid_principal_via_mapper() {
let paths = keycloak_claim_paths("my-client");
let mapper = JsonPointerClaimsMapper::new(paths);
let claims = json!({
"sub": "user-1",
"iss": "https://kc.example.com/realms/test",
"aud": "my-api",
"realm_access": { "roles": ["admin"] },
"resource_access": {
"my-client": { "roles": ["client-role"] }
},
"scope": "read write",
});
let principal = mapper.to_principal(&claims).unwrap();
assert_eq!(principal.subject, "user-1");
assert!(principal.has_role("admin"));
assert!(principal.has_role("client-role"));
assert_eq!(principal.scopes, vec!["read", "write"]);
}
#[test]
fn realm_url() {
let config = KeycloakRealmConfig::new(
"http://localhost:8080".into(),
"my-realm".into(),
"my-client".into(),
);
assert_eq!(config.realm_url(), "http://localhost:8080/realms/my-realm");
}
#[test]
fn jwks_uri() {
let config = KeycloakRealmConfig::new(
"http://localhost:8080".into(),
"my-realm".into(),
"my-client".into(),
);
assert_eq!(
config.jwks_uri(),
"http://localhost:8080/realms/my-realm/protocol/openid-connect/certs"
);
}
#[test]
fn token_endpoint() {
let config = KeycloakRealmConfig::new(
"http://localhost:8080".into(),
"my-realm".into(),
"my-client".into(),
);
assert_eq!(
config.token_endpoint(),
"http://localhost:8080/realms/my-realm/protocol/openid-connect/token"
);
}
#[test]
fn trailing_slash_handling() {
let config = KeycloakRealmConfig::new(
"http://localhost:8080/".into(),
"test".into(),
"client".into(),
);
assert_eq!(config.realm_url(), "http://localhost:8080/realms/test");
}
#[test]
fn debug_redacts_client_secret() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"myrealm".into(),
"myclient".into(),
)
.with_client_secret("super-secret".into());
let debug_str = format!("{config:?}");
assert!(!debug_str.contains("super-secret"));
assert!(debug_str.contains("REDACTED"));
}
#[test]
fn empty_client_id_produces_malformed_resource_path() {
let paths = keycloak_claim_paths("");
assert!(
paths.roles.iter().any(|p| p == "/resource_access//roles"),
"empty client_id should produce /resource_access//roles — caller must validate"
);
}
#[test]
fn keycloak_config_client_secret_accessor_with_secret() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"myrealm".into(),
"myclient".into(),
)
.with_client_secret("secret-123".into());
assert_eq!(config.client_secret(), Some("secret-123"));
}
#[test]
fn keycloak_config_client_secret_accessor_without_secret() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"myrealm".into(),
"myclient".into(),
);
assert!(config.client_secret().is_none());
}
#[test]
fn keycloak_component_scheme() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"myrealm".into(),
"myclient".into(),
)
.with_client_secret("secret".into());
let component = KeycloakComponent::new(&config).unwrap();
assert_eq!(component.scheme(), "keycloak");
}
#[test]
fn keycloak_component_create_endpoint_valid() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"myrealm".into(),
"myclient".into(),
)
.with_client_secret("secret".into());
let component = KeycloakComponent::new(&config).unwrap();
let ctx = NoOpComponentContext;
let endpoint = component
.create_endpoint(
"keycloak:admin?operation=getUser&realm=myrealm&userId=user-1",
&ctx,
)
.unwrap();
assert_eq!(
endpoint.uri(),
"keycloak:admin?operation=getUser&realm=myrealm&userId=user-1"
);
}
#[test]
fn keycloak_component_create_endpoint_invalid() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"myrealm".into(),
"myclient".into(),
)
.with_client_secret("secret".into());
let component = KeycloakComponent::new(&config).unwrap();
let ctx = NoOpComponentContext;
let result = component.create_endpoint("keycloak:badpath", &ctx);
assert!(result.is_err());
}
#[test]
fn introspection_authenticator_builder_derives_endpoint() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"test-realm".into(),
"my-client".into(),
)
.with_client_secret("secret".into());
let opts = IntrospectionCacheOptions::default();
let result = config.introspection_authenticator(opts);
assert!(result.is_ok(), "builder should succeed with client_secret");
}
#[test]
fn introspection_authenticator_builder_requires_client_secret() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"test-realm".into(),
"my-client".into(),
);
let opts = IntrospectionCacheOptions::default();
let result = config.introspection_authenticator(opts);
assert!(result.is_err(), "builder should fail without client_secret");
let err = result.unwrap_err();
match err {
CamelError::Config(msg) => assert!(msg.contains("client_secret")),
other => panic!("expected Config error, got: {other:?}"),
}
}
#[test]
fn introspection_authenticator_maps_keycloak_roles() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"test-realm".into(),
"svc".into(),
)
.with_client_secret("s".into());
let opts = IntrospectionCacheOptions::default();
let _auth = config.introspection_authenticator(opts).unwrap();
let claims = json!({
"active": true,
"sub": "user-1",
"realm_access": {"roles": ["admin"]},
"resource_access": {"svc": {"roles": ["svc-role"]}},
"scope": "read"
});
let mapper = JsonPointerClaimsMapper::new(keycloak_claim_paths("svc"));
let principal = mapper.to_principal(&claims).unwrap();
assert!(principal.has_role("admin"));
assert!(principal.has_role("svc-role"));
assert_eq!(principal.scopes, vec!["read"]);
}
#[test]
fn uma_evaluator_builder_returns_evaluator() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"test".into(),
"authz-client".into(),
)
.with_client_secret("secret".into());
let evaluator = config.uma_evaluator();
assert!(evaluator.is_ok());
let eval = evaluator.unwrap();
let _arc: Arc<dyn PermissionEvaluator> = eval;
}
#[test]
fn uma_evaluator_fails_without_client_secret() {
let config = KeycloakRealmConfig::new(
"https://kc.example.com".into(),
"test".into(),
"authz-client".into(),
);
let result = config.uma_evaluator();
assert!(result.is_err());
}
}