pdk-contracts-lib 1.10.0

PDK Contracts Library
Documentation
// Copyright (c) 2026, Salesforce, Inc.,
// All rights reserved.
// For full license text, see the LICENSE.txt file

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,
}

/// Provides authentication and authorization decisions against Anypoint Platform API contracts.
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,
}

/// Internal error to unify code
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 {
    /// Validates if `client_id` can be authorized against the current API contracts.
    /// Returns the [`ClientData`] of `client_id` on success, or an [`AuthorizationError`].
    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?)
    }

    /// Validates if `client_id` and `client_secret` can be authenticated against the current API contracts.
    /// Returns the [`ClientData`] of `client_id` on success, or an [`AuthenticationError`].
    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)
            // Default should never happen since we check existence of field before storing.
            .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()),
    }
}