use crate::api::credentials::{ClientId, ClientSecret};
use crate::api::error::{AuthenticationError, AuthorizationError};
use crate::api::ClientData;
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use lru::LruCache;
use pdk_core::classy::client::{HttpClient, HttpClientError, Service, Uri};
use pdk_core::classy::extract::context::ConfigureContext;
use pdk_core::classy::extract::{Extract, FromContext};
use pdk_core::classy::Clock;
use pdk_core::host::property::PropertyAccessor;
use pdk_core::log::debug;
use pdk_core::policy_context::api::Metadata;
use pdk_core::policy_context::metadata::PolicyMetadata;
use serde::{Deserialize, Serialize};
use std::cell::RefCell;
use std::num::NonZeroUsize;
use std::rc::Rc;
use std::str::FromStr;
use std::time::{Duration, UNIX_EPOCH};
const VALIDATION_PATH: &str = "/contracts/introspect";
const TIMEOUT: Duration = Duration::from_secs(10);
const INTENT_AUTHN: &str = "authn";
const INTENT_AUTHZ: &str = "authz";
const CACHE_MAX_ENTRIES: usize = 256;
#[derive(Serialize, Deserialize, Clone)]
struct IntrospectionResponse {
active: bool,
tier: Option<IntrospectionTier>,
scope: Option<String>,
username: Option<String>,
exp: Option<u64>,
}
#[derive(Serialize, Deserialize, Clone)]
struct IntrospectionTier {
id: String,
name: String,
}
pub struct ApiContracts {
api_id: String,
http_client: HttpClient,
service: Service,
path: String,
clock: Rc<dyn Clock>,
cache: RefCell<LruCache<String, IntrospectionResponse>>,
}
#[derive(thiserror::Error, Debug)]
pub enum ExtractionError {
#[error("Api metadata is unavailable.")]
ApiMetadata,
#[error("Environment Context is unavailable.")]
EnvironmentContext,
}
enum AuthError {
HttpClient,
Inactive,
}
impl From<AuthError> for AuthorizationError {
fn from(value: AuthError) -> Self {
match value {
AuthError::HttpClient => AuthorizationError::UnavailableContracts,
AuthError::Inactive => AuthorizationError::InvalidClientId,
}
}
}
impl From<AuthError> for AuthenticationError {
fn from(value: AuthError) -> Self {
match value {
AuthError::HttpClient => AuthenticationError::UnavailableContracts,
AuthError::Inactive => AuthenticationError::InvalidClientId,
}
}
}
impl FromContext<ConfigureContext> for ApiContracts {
type Error = ExtractionError;
fn from_context(context: &ConfigureContext) -> Result<Self, Self::Error> {
let metadata: Metadata = context.extract_always();
let api_id = metadata
.api_metadata
.id
.ok_or(ExtractionError::ApiMetadata)?
.trim_end_matches(".svc")
.to_string();
let http_client = context.extract_always();
let clock: Rc<dyn Clock> = context.extract_always();
let property_accessor: &dyn PropertyAccessor = context.extract_always();
let metadata = PolicyMetadata::from(property_accessor);
let environment = metadata
.anypoint_environment()
.ok_or(ExtractionError::EnvironmentContext)?;
let anypoint = environment
.anypoint()
.ok_or(ExtractionError::EnvironmentContext)?;
let uri = Uri::from_str(anypoint.url()).map_err(|e| {
debug!("Unexpected URI: {e}");
ExtractionError::EnvironmentContext
})?;
let path = format!("{}{}", uri.path(), VALIDATION_PATH);
let service = Service::new(anypoint.service_name(), uri);
Ok(Self {
api_id,
http_client,
service,
clock,
path,
cache: RefCell::new(LruCache::new(NonZeroUsize::new(CACHE_MAX_ENTRIES).unwrap())),
})
}
}
impl ApiContracts {
pub async fn authorize(&self, client_id: &ClientId) -> Result<ClientData, AuthorizationError> {
let token = STANDARD.encode(format!("{}:", client_id.as_str()));
let cache_key = format!("authz:{}", client_id.as_str());
Ok(self.auth(client_id, cache_key, token, INTENT_AUTHZ).await?)
}
pub async fn authenticate(
&self,
client_id: &ClientId,
client_secret: &ClientSecret,
) -> Result<ClientData, AuthenticationError> {
let token = STANDARD.encode(format!("{}:{}", client_id.as_str(), client_secret.as_str()));
let cache_key = format!("authn:{token}");
Ok(self.auth(client_id, cache_key, token, INTENT_AUTHN).await?)
}
async fn auth(
&self,
client_id: &ClientId,
cache_key: String,
token: String,
intent: &str,
) -> Result<ClientData, AuthError> {
if let Some(cached) = self.cached_response(&cache_key) {
return Self::auth_result(client_id, cached);
}
let response = self
.introspect(&token, intent)
.await
.map_err(|_| AuthError::HttpClient)?;
self.insert_cache(cache_key, &response);
Self::auth_result(client_id, response)
}
fn auth_result(
client_id: &ClientId,
response: IntrospectionResponse,
) -> Result<ClientData, AuthError> {
if !response.active {
return Err(AuthError::Inactive);
}
Ok(client_data_from(client_id, response))
}
async fn introspect(
&self,
token: &str,
intent: &str,
) -> Result<IntrospectionResponse, HttpClientError> {
let body = serde_urlencoded::to_string([
("token", token),
("token_type_hint", self.api_id.as_str()),
("intent", intent),
])
.unwrap_or_default();
let response = self
.http_client
.request(&self.service)
.path(&self.path)
.headers(vec![("content-type", "application/x-www-form-urlencoded")])
.body(body.as_bytes())
.timeout(TIMEOUT)
.post()
.await?;
serde_json::from_slice(response.body()).map_err(|_| {
HttpClientError::Status(pdk_core::classy::proxy_wasm::types::Status::InternalFailure)
})
}
fn now_secs(&self) -> u64 {
self.clock
.get_current_time()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn cached_response(&self, key: &str) -> Option<IntrospectionResponse> {
let now = self.now_secs();
self.cache
.borrow_mut()
.get(key)
.filter(|cache| cache.exp.unwrap_or_default() > now)
.cloned()
}
fn insert_cache(&self, key: String, response: &IntrospectionResponse) {
if response.exp.is_none() {
return;
};
self.cache.borrow_mut().push(key, response.clone());
}
}
fn client_data_from(client_id: &ClientId, response: IntrospectionResponse) -> ClientData {
ClientData {
client_id: client_id.to_string(),
client_name: response.username.unwrap_or_default(),
sla_id: response.tier.map(|t| t.id.clone()),
}
}