#![deny(unsafe_code, rust_2018_idioms, clippy::unwrap_used)]
#![warn(rust_2024_compatibility, clippy::pedantic)]
#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
mod cache;
pub mod cert;
pub mod config;
mod http_client;
#[derive(Debug, serde::Deserialize, Clone)]
#[serde(untagged)]
#[allow(dead_code)]
enum Audience {
Single(String),
Multiple(Vec<String>),
}
#[derive(Debug, serde::Serialize, Clone)]
struct TokenClaims {
#[serde(rename = "@type")]
type_: String,
#[serde(rename = "@context")]
context_: String,
iss: String,
sub: String,
id: String,
jti: String,
aud: String,
iat: i64,
exp: i64,
nbf: i64,
}
#[derive(Debug, serde::Deserialize, Clone)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
pub scope: Option<String>,
}
#[derive(Debug, serde::Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct DatClaims {
#[serde(rename = "@type")]
type_: String,
#[serde(rename = "@context")]
context_: String,
referring_connector: String,
security_profile: String,
#[serde(rename = "iat")]
issued_at: i64,
#[serde(rename = "exp")]
expires_at: i64,
#[serde(rename = "nbf")]
not_before: i64,
#[serde(rename = "sub")]
subject: String,
#[serde(rename = "aud")]
audience: Audience,
#[serde(rename = "iss")]
issuer: String,
#[serde(rename = "jti")]
jwt_id: String,
}
#[derive(thiserror::Error, Debug)]
pub enum DapsError {
#[error("http client error: {0}")]
DapsHttpClient(#[from] http_client::DapsHttpClientError),
#[error("jwt error")]
InvalidToken,
#[error("cache error: {0}")]
CacheError(#[from] cache::CertificatesCacheError),
}
pub type ReqwestDapsClient = DapsClient<http_client::reqwest_client::ReqwestDapsClient>;
pub struct DapsClient<C> {
client: C,
sub: String,
certs_url: String,
token_url: String,
scope: String,
encoding_key: jsonwebtoken::EncodingKey,
uuid_context: uuid::ContextV7,
certs_cache: cache::CertificatesCache,
}
impl<C> DapsClient<C>
where
C: http_client::DapsClientRequest,
{
#[must_use]
pub fn new(config: &config::DapsConfig<'_>) -> Self {
let (ski_aki, private_key) = cert::ski_aki_and_private_key_from_file(
config.private_key.as_ref(),
config.private_key_password.as_deref().unwrap_or(""),
)
.expect("Reading SKI:AKI failed");
let encoding_key = jsonwebtoken::EncodingKey::from_rsa_der(private_key.as_ref());
Self {
client: C::default(),
sub: ski_aki.to_string(),
scope: config.scope.to_string(),
certs_url: config.certs_url.to_string(),
token_url: config.token_url.to_string(),
encoding_key,
uuid_context: uuid::ContextV7::new(),
certs_cache: cache::CertificatesCache::new(std::time::Duration::from_secs(
config.certs_cache_ttl,
)),
}
}
pub async fn validate_dat(
&self,
token: &str,
) -> Result<jsonwebtoken::TokenData<DatClaims>, DapsError> {
let jwks = self.get_certs().await?;
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
validation.sub = Some(self.sub.to_string());
validation.set_audience(&["idsc:IDS_CONNECTORS_ALL"]);
validation.set_required_spec_claims(&["exp", "nbf", "aud", "iss", "sub"]);
let validation_results: Vec<jsonwebtoken::TokenData<_>> = jwks
.keys
.iter()
.filter_map(|jwk| {
if let Ok(jwk) = jsonwebtoken::DecodingKey::from_jwk(jwk) {
let result = jsonwebtoken::decode(token, &jwk, &validation);
tracing::debug!("Validation result: {:?}", result);
result.ok()
} else {
None
}
})
.collect();
validation_results
.first()
.ok_or(DapsError::InvalidToken)
.cloned()
}
pub async fn request_dat(&self) -> Result<String, DapsError> {
let now = chrono::Utc::now();
let now_secs = now.timestamp();
let now_subsec_nanos = now.timestamp_subsec_nanos();
#[allow(clippy::cast_sign_loss)]
let uuid_timestamp =
uuid::Timestamp::from_unix(&self.uuid_context, now_secs as u64, now_subsec_nanos);
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
let claims = TokenClaims {
context_: "https://w3id.org/idsa/contexts/context.jsonld".to_string(),
type_: "ids:DatRequestToken".to_string(),
jti: uuid::Uuid::new_v7(uuid_timestamp).hyphenated().to_string(),
iss: self.sub.to_string(),
sub: self.sub.to_string(),
id: self.sub.to_string(),
aud: self.scope.to_string(),
iat: now_secs,
exp: now_secs + 3600,
nbf: now_secs,
};
let token = jsonwebtoken::encode(&header, &claims, &self.encoding_key)
.expect("Token signing failed. There must be something wrong with the private key.");
tracing::debug!("Issued TokenRequest (requestDAT): {}", token);
let response = self
.client
.request_token(
self.token_url.as_ref(),
&[
("grant_type", "client_credentials"),
("scope", "idsc:IDS_CONNECTOR_ATTRIBUTES_ALL"),
(
"client_assertion_type",
"urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
),
("client_assertion", &token),
],
)
.await?;
Ok(response.access_token)
}
pub async fn get_jwks(&self) -> Result<jsonwebtoken::jwk::JwkSet, DapsError> {
self.get_certs().await
}
async fn update_cert_cache(&self) -> Result<jsonwebtoken::jwk::JwkSet, DapsError> {
let jwks = self.client.get_certs(self.certs_url.as_ref()).await?;
self.certs_cache
.update(jwks.clone())
.await
.map_err(DapsError::from)
}
async fn get_certs(&self) -> Result<jsonwebtoken::jwk::JwkSet, DapsError> {
tracing::debug!("Checking cache...");
match self.certs_cache.get().await {
Ok(cert) => {
tracing::debug!("Cache is up-to-date");
Ok(cert)
}
Err(cache::CertificatesCacheError::Outdated) => {
tracing::info!("Cache is outdated, updating...");
self.update_cert_cache().await
}
Err(cache::CertificatesCacheError::Empty) => {
tracing::info!("Cache is empty, updating...");
self.update_cert_cache().await
}
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[tokio::test]
async fn integration_test() {
use testcontainers::runners::AsyncRunner;
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::new("ids_daps=DEBUG"))
.init();
let image = testcontainers::GenericImage::new("ghcr.io/ids-basecamp/daps", "test");
let container = image
.with_exposed_port(4567.into()) .with_wait_for(testcontainers::core::WaitFor::message_on_stdout(
"Listening on 0.0.0.0:4567, CTRL+C to stop",
))
.start()
.await
.expect("Failed to start DAPS container. Is Docker running?");
let host = container.get_host().await.expect("Failed to get host");
let host_port = container
.get_host_port_ipv4(4567)
.await
.expect("Failed to get port");
let certs_url = format!("http://{host}:{host_port}/jwks.json");
let token_url = format!("http://{host}:{host_port}/token");
let config = config::DapsConfigBuilder::default()
.certs_url(certs_url)
.token_url(token_url)
.private_key(std::path::Path::new("./testdata/connector-certificate.p12"))
.private_key_password(Some(std::borrow::Cow::from("Password1")))
.scope(std::borrow::Cow::from("idsc:IDS_CONNECTORS_ALL"))
.certs_cache_ttl(1_u64)
.build()
.expect("Failed to build DAPS-Config");
let client: ReqwestDapsClient = DapsClient::new(&config);
let dat = client.request_dat().await.unwrap();
tracing::info!("DAT Token: {:?}", dat);
let cache1_start = std::time::Instant::now();
if let Err(err) = client.validate_dat(&dat).await {
tracing::error!("Validation failed: {:?}", err);
panic!("Validation failed");
} else {
assert!(client.validate_dat(&dat).await.is_ok());
}
tracing::debug!("First validation took {:?}", cache1_start.elapsed());
let cache2_start = std::time::Instant::now();
assert!(client.validate_dat(&dat).await.is_ok());
tracing::debug!("Second validation took {:?}", cache2_start.elapsed());
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let cache3_start = std::time::Instant::now();
assert!(client.validate_dat(&dat).await.is_ok());
tracing::debug!("Third validation took {:?}", cache3_start.elapsed());
}
}