use std::sync::Arc;
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use camel_auth::oauth2::{ClientCredentialsProvider, TokenProvider};
use camel_auth::permission::{PermissionDecision, PermissionEvaluator, PermissionRequest};
use camel_auth::types::AuthError;
pub struct KeycloakUmaEvaluator {
realm_url: String,
client_id: String,
http: reqwest::Client,
token_provider: Arc<ClientCredentialsProvider>,
}
impl std::fmt::Debug for KeycloakUmaEvaluator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeycloakUmaEvaluator")
.field("realm_url", &self.realm_url)
.field("client_id", &self.client_id)
.finish_non_exhaustive()
}
}
impl KeycloakUmaEvaluator {
pub fn new(
server_url: String,
realm: String,
client_id: String,
client_secret: String,
) -> Result<Self, AuthError> {
let server_url_trimmed = server_url.trim_end_matches('/');
let realm_url = format!("{}/realms/{}", server_url_trimmed, realm);
let token_endpoint = format!("{}/protocol/openid-connect/token", realm_url);
camel_auth::validate_https_public_uri(&token_endpoint, "UMA token endpoint URI")?;
let token_provider = ClientCredentialsProvider::new(
token_endpoint,
client_id.clone(),
client_secret,
None,
None,
);
Ok(Self {
realm_url,
client_id,
http: reqwest::Client::new(),
token_provider: Arc::new(token_provider),
})
}
#[doc(hidden)]
pub fn new_unchecked_for_test(
server_url: &str,
realm: &str,
client_id: &str,
client_secret: &str,
http: reqwest::Client,
) -> Self {
let server_url_trimmed = server_url.trim_end_matches('/');
let realm_url = format!("{}/realms/{}", server_url_trimmed, realm);
let token_endpoint = format!("{}/protocol/openid-connect/token", realm_url); let token_provider = ClientCredentialsProvider::new_unchecked_for_test(
token_endpoint,
client_id.to_string(),
client_secret.to_string(),
None,
None,
http.clone(),
);
Self {
realm_url,
client_id: client_id.to_string(),
http,
token_provider: Arc::new(token_provider),
}
}
fn permission_endpoint(&self) -> String {
format!("{}/protocol/openid-connect/token", self.realm_url) }
fn build_permission_string(resource: &str, action: &str, scopes: &[String]) -> String {
if scopes.is_empty() {
format!("{}#{}", resource, action)
} else {
let scopes_joined = scopes.join(",");
format!("{}#{}#{}", resource, action, scopes_joined) }
}
}
#[async_trait]
impl PermissionEvaluator for KeycloakUmaEvaluator {
async fn evaluate(&self, request: PermissionRequest) -> Result<PermissionDecision, AuthError> {
let access_token = self.token_provider.get_token().await?;
let permission_str = Self::build_permission_string(
&request.resource,
&request.action,
&request.requested_scopes,
);
let claim_json = serde_json::to_string(&request.principal.claims).map_err(|e| {
AuthError::ProviderUnavailable(format!("failed to serialize principal claims: {e}"))
})?;
let claim_token = BASE64.encode(claim_json);
let response = self
.http
.post(self.permission_endpoint())
.header("Authorization", format!("Bearer {}", access_token)) .form(&[
("grant_type", "urn:ietf:params:oauth:grant-type:uma-ticket"),
("audience", &self.client_id),
("permission", &permission_str),
("claim_token", &claim_token),
("claim_token_format", "urn:ietf:params:oauth:token-type:jwt"),
])
.send()
.await
.map_err(|e| {
AuthError::ProviderUnavailable(format!("UMA permission request failed: {e}"))
})?;
let status = response.status();
match status.as_u16() {
200 => Ok(PermissionDecision::Granted),
403 => {
let body = response.text().await.unwrap_or_default();
let parsed: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|_| serde_json::json!({"error_description": "access denied"}));
let description = parsed
.get("error_description")
.and_then(|v| v.as_str())
.unwrap_or("access denied");
let reason = description.to_string();
Ok(PermissionDecision::Denied { reason })
}
401 => Err(AuthError::ProviderUnavailable(
"client credentials rejected".into(),
)),
_ => Err(AuthError::ProviderUnavailable(format!(
"UMA endpoint returned {}",
status
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use camel_api::security_policy::Principal;
use serde_json::json;
fn test_principal() -> Principal {
Principal {
subject: "alice".into(),
issuer: "https://kc.example.com/realms/test".into(),
audience: vec!["camel-api".into()],
roles: vec!["admin".into()],
scopes: vec!["read".into()],
claims: json!({}),
}
}
#[test]
fn build_permission_string_without_scopes() {
let result = KeycloakUmaEvaluator::build_permission_string("/orders", "read", &[]);
assert_eq!(result, "/orders#read");
}
#[test]
fn build_permission_string_with_scopes() {
let result = KeycloakUmaEvaluator::build_permission_string(
"/orders",
"read",
&["scope1".to_string(), "scope2".to_string()],
);
assert_eq!(result, "/orders#read#scope1,scope2");
}
#[test]
fn build_permission_string_with_single_scope() {
let result = KeycloakUmaEvaluator::build_permission_string(
"/data",
"write",
&["exclusive".to_string()],
);
assert_eq!(result, "/data#write#exclusive");
}
#[test]
fn new_rejects_non_https() {
let result = KeycloakUmaEvaluator::new(
"http://localhost:8080".into(),
"test".into(),
"client".into(),
"secret".into(),
);
assert!(result.is_err(), "should reject non-HTTPS server URL");
}
#[test]
fn new_unchecked_for_test_builds_successfully() {
let evaluator = KeycloakUmaEvaluator::new_unchecked_for_test(
"http://localhost:8080",
"test-realm",
"test-client",
"test-secret",
reqwest::Client::new(),
);
assert_eq!(evaluator.client_id, "test-client");
assert_eq!(
evaluator.realm_url,
"http://localhost:8080/realms/test-realm"
);
}
#[test]
fn permission_endpoint_derives_correctly() {
let evaluator = KeycloakUmaEvaluator::new_unchecked_for_test(
"https://kc.example.com",
"myrealm",
"myclient",
"secret",
reqwest::Client::new(),
);
assert_eq!(
evaluator.permission_endpoint(),
"https://kc.example.com/realms/myrealm/protocol/openid-connect/token"
);
}
#[test]
fn trailing_slash_stripped_in_realm_url() {
let evaluator = KeycloakUmaEvaluator::new_unchecked_for_test(
"https://kc.example.com/",
"myrealm",
"myclient",
"secret",
reqwest::Client::new(),
);
assert_eq!(evaluator.realm_url, "https://kc.example.com/realms/myrealm");
}
#[test]
fn debug_hides_secrets() {
let evaluator = KeycloakUmaEvaluator::new_unchecked_for_test(
"https://kc.example.com",
"myrealm",
"myclient",
"super-secret-value",
reqwest::Client::new(),
);
let debug_str = format!("{evaluator:?}");
assert!(!debug_str.contains("super-secret-value"));
assert!(debug_str.contains("KeycloakUmaEvaluator"));
}
#[test]
fn claim_token_encodes_principal_claims() {
let claims = json!({
"sub": "alice",
"email": "alice@example.com",
"roles": ["admin", "user"]
});
let encoded = BASE64.encode(serde_json::to_string(&claims).unwrap());
let decoded_bytes = BASE64.decode(&encoded).unwrap();
let decoded: serde_json::Value = serde_json::from_slice(&decoded_bytes).unwrap();
assert_eq!(decoded, claims);
assert!(!encoded.contains('\n'));
}
#[test]
fn claim_token_encodes_null_claims_gracefully() {
let claims = serde_json::Value::Null;
let encoded = BASE64.encode(serde_json::to_string(&claims).unwrap());
let decoded_bytes = BASE64.decode(&encoded).unwrap();
let decoded: serde_json::Value = serde_json::from_slice(&decoded_bytes).unwrap();
assert_eq!(decoded, serde_json::Value::Null);
}
}