#![cfg(feature = "integration-tests")]
mod support;
use support::install_crypto_provider;
use std::sync::Arc;
use std::time::Duration;
use camel_api::security_policy::{CredentialSource, Principal, SecurityPolicyConfig};
use camel_api::{CamelError, Value};
use camel_auth::native_auth::{NativeCredential, NativeCredentialSecret, NativeCredentialStore};
use camel_auth::{RolePolicy, StaticTokenAuthenticator, TokenAuthenticator};
use camel_builder::{RouteBuilder, StepAccumulator};
use camel_component_http::HttpComponent;
use camel_config::CamelConfig;
use camel_config::config::NativeAuthConfig;
use camel_test::CamelTestContext;
fn find_free_port() -> u16 {
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:0").expect("failed to bind to free port");
listener.local_addr().unwrap().port()
}
fn native_principal(subject: &str, issuer: &str, roles: &[String], scopes: &[String]) -> Principal {
Principal {
subject: subject.to_string(),
issuer: issuer.to_string(),
audience: Vec::new(),
roles: roles.to_vec(),
scopes: scopes.to_vec(),
claims: serde_json::json!({}),
}
}
fn native_store_from_config(
native: &NativeAuthConfig,
) -> Result<NativeCredentialStore, CamelError> {
let issuer = native.issuer.clone().unwrap_or_else(|| "native".into());
let mut credentials: Vec<NativeCredential> = Vec::new();
if let Some(token) = &native.bearer_token {
credentials.push(NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: token.clone().into(),
},
principal: native_principal(&native.subject, &issuer, &native.roles, &native.scopes),
});
}
if let Some(api_key) = &native.api_key {
credentials.push(NativeCredential {
secret: NativeCredentialSecret::Plaintext {
value: api_key.clone().into(),
},
principal: native_principal(&native.subject, &issuer, &native.roles, &native.scopes),
});
}
for entry in &native.credentials {
let secret = match (&entry.secret_env, &entry.secret) {
(Some(name), None) => NativeCredentialSecret::Env { name: name.clone() },
(None, Some(value)) => NativeCredentialSecret::Plaintext {
value: value.clone().into(),
},
_ => {
return Err(CamelError::Config(
"security.native.credentials must set exactly one of secret_env or secret"
.to_string(),
));
}
};
credentials.push(NativeCredential {
secret,
principal: native_principal(&entry.subject, &issuer, &entry.roles, &entry.scopes),
});
}
if credentials.is_empty() {
return Err(CamelError::Config(
"security.native configured without any credential: set bearer_token, api_key, or [[security.native.credentials]]"
.into(),
));
}
NativeCredentialStore::try_new(credentials)
}
async fn build_secure_route(
native: &NativeAuthConfig,
path: &str,
required_roles: Vec<String>,
sources: Vec<CredentialSource>,
) -> (CamelTestContext, u16) {
install_crypto_provider();
let port = find_free_port();
let h = CamelTestContext::builder()
.with_component(HttpComponent::new())
.with_mock()
.build()
.await;
let store = native_store_from_config(native).expect("store builds from config");
let authenticator: Arc<dyn TokenAuthenticator> = Arc::new(StaticTokenAuthenticator::new(store));
let policy = RolePolicy::new(
required_roles,
true,
false,
Arc::clone(&authenticator),
sources.clone(),
);
let config = SecurityPolicyConfig::new(policy).with_credential_sources(sources);
let route = RouteBuilder::from(&format!("http://0.0.0.0:{port}/{path}"))
.route_id(format!("secure-http-{path}"))
.security_policy(config)
.security_authenticator(authenticator)
.set_body(Value::String("ok".into()))
.set_header("CamelHttpResponseCode", Value::Number(200.into()))
.to(format!("mock:{path}"))
.build()
.unwrap();
h.add_route(route).await.unwrap();
h.start().await;
tokio::time::sleep(Duration::from_millis(100)).await;
(h, port)
}
fn load_camel_toml(toml_str: &str) -> CamelConfig {
let dir = tempfile::TempDir::new().expect("tempdir");
let path = dir.path().join("Camel.toml");
std::fs::write(&path, toml_str).expect("write Camel.toml");
let config = CamelConfig::from_file(path.to_str().unwrap()).expect("Camel.toml loads");
config
}
#[tokio::test(flavor = "multi_thread")]
async fn two_principals_enforce_roles_e2e() {
const ENV_VAR: &str = "CAMEL_TEST_OPS_TOKEN";
const OPS_TOKEN: &str = "ops-secret-token";
const SVC_TOKEN: &str = "svc-plaintext-token";
unsafe { std::env::set_var(ENV_VAR, OPS_TOKEN) };
let toml_str = format!(
r#"
[security.native]
subject = "native"
issuer = "native"
[[security.native.credentials]]
subject = "ops"
secret_env = "{ENV_VAR}"
roles = ["admin"]
[[security.native.credentials]]
subject = "svc"
secret = "{SVC_TOKEN}"
roles = ["service"]
"#
);
let config = load_camel_toml(&toml_str);
let native = config
.security
.native
.as_ref()
.expect("native config present");
assert_eq!(
native.credentials.len(),
2,
"two credentials must deserialize"
);
let (_h, port) = build_secure_route(
native,
"secure",
vec!["admin".to_string()],
vec![CredentialSource::AuthorizationHeader],
)
.await;
let client = reqwest::Client::new();
let resp = client
.get(format!("http://127.0.0.1:{port}/secure"))
.header("Authorization", format!("Bearer {OPS_TOKEN}"))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "ops admin token must be authorized");
let resp = client
.get(format!("http://127.0.0.1:{port}/secure"))
.header("Authorization", format!("Bearer {SVC_TOKEN}"))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 403, "svc service token must be forbidden");
let resp = client
.get(format!("http://127.0.0.1:{port}/secure"))
.header("Authorization", "Bearer unknown-token")
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401, "unknown token must be unauthenticated");
unsafe { std::env::remove_var(ENV_VAR) };
}
#[tokio::test(flavor = "multi_thread")]
async fn api_key_only_custom_header_enforces_e2e() {
const API_KEY: &str = "reader-api-key-123";
const HEADER: &str = "X-API-Key";
let toml_str = format!(
r#"
[security.native]
subject = "reader-user"
api_key = "{API_KEY}"
roles = ["reader"]
"#
);
let config = load_camel_toml(&toml_str);
let native = config
.security
.native
.as_ref()
.expect("native config present");
assert!(
native.credentials.is_empty(),
"api_key-only config must have no credentials entries"
);
let (_h, port) = build_secure_route(
native,
"apikey",
vec!["reader".to_string()],
vec![CredentialSource::Header {
name: HEADER.to_string(),
}],
)
.await;
let client = reqwest::Client::new();
let ok = client
.get(format!("http://127.0.0.1:{port}/apikey"))
.header(HEADER, API_KEY)
.send()
.await
.unwrap();
assert_eq!(ok.status(), 200, "valid API key must be authorized");
let missing = client
.get(format!("http://127.0.0.1:{port}/apikey"))
.send()
.await
.unwrap();
assert_eq!(
missing.status(),
401,
"missing API key must be unauthenticated"
);
}