securitydept-token-set-context 0.3.0-beta.3

Token Set Context of SecurityDept, a layered authentication and authorization toolkit built as reusable Rust crates.
Documentation
use securitydept_oauth_provider::OidcSharedConfig;
use securitydept_oauth_resource_server::OAuthResourceServerConfig;
use serde::Deserialize;

use super::capabilities::TokenPropagation;
use crate::orchestration::BackendConfigError;

pub mod validator;

pub use validator::{
    AccessTokenSubstrateConfigValidationError, AccessTokenSubstrateConfigValidator,
    NoopAccessTokenSubstrateConfigValidator,
};

// ---------------------------------------------------------------------------
// Config source trait
// ---------------------------------------------------------------------------

/// Trait for types that supply access-token substrate configuration components.
///
/// Mirrors [`BackendOidcModeConfigSource`] for the substrate layer.
/// Implementors expose component-config accessors and gain default `resolve_*`
/// helper methods that apply `[oidc]` shared defaults and validate each
/// component.
///
/// [`BackendOidcModeConfigSource`]: crate::backend_oidc_mode::BackendOidcModeConfigSource
pub trait AccessTokenSubstrateConfigSource {
    // --- Component accessors ---

    /// Access the raw `[oauth_resource_server]` config block.
    fn resource_server_config(&self) -> &OAuthResourceServerConfig;

    /// Access the token propagation capability axis.
    fn token_propagation(&self) -> &TokenPropagation;

    // --- Resolve helpers (default implementations) ---

    /// Apply OIDC shared defaults to the resource-server config and validate.
    ///
    /// Delegates to [`OAuthResourceServerConfig::resolve_config`] which handles
    /// `well_known_url`, `client_id`, `client_secret` inheritance from the
    /// `[oidc]` shared block.
    fn resolve_resource_server(
        &self,
        shared: &OidcSharedConfig,
    ) -> Result<OAuthResourceServerConfig, BackendConfigError> {
        let mut rs = self.resource_server_config().clone();
        rs.resolve_config(shared)?;
        Ok(rs)
    }

    /// **Recommended entry point.** Resolve all substrate sub-configs in one
    /// step.
    ///
    /// When an `[oidc]` shared config is provided, resource-server config
    /// inherits provider defaults. When `None`, resource-server config is
    /// returned as-is (valid for deployments without OIDC discovery).
    fn resolve_all(
        &self,
        shared: Option<&OidcSharedConfig>,
    ) -> Result<ResolvedAccessTokenSubstrateConfig, BackendConfigError> {
        let validator = NoopAccessTokenSubstrateConfigValidator;
        self.resolve_all_with_validator(shared, &validator)
    }

    fn resolve_all_with_validator<V>(
        &self,
        shared: Option<&OidcSharedConfig>,
        validator: &V,
    ) -> Result<ResolvedAccessTokenSubstrateConfig, BackendConfigError>
    where
        V: AccessTokenSubstrateConfigValidator,
    {
        let raw_config = AccessTokenSubstrateConfig {
            resource_server: self.resource_server_config().clone(),
            token_propagation: self.token_propagation().clone(),
        };
        validator
            .validate_raw_access_token_substrate_config(&raw_config)
            .map_err(BackendConfigError::AccessTokenSubstrateValidation)?;

        let resource_server = if let Some(shared) = shared {
            self.resolve_resource_server(shared)?
        } else {
            self.resource_server_config().clone()
        };

        Ok(ResolvedAccessTokenSubstrateConfig {
            resource_server,
            token_propagation: self.token_propagation().clone(),
        })
    }
}

// ---------------------------------------------------------------------------
// Raw config (TOML / env deserialisable)
// ---------------------------------------------------------------------------

/// Unified configuration for the access-token substrate layer.
///
/// This struct owns the configuration for all cross-mode substrate concerns:
/// resource-server verification and token propagation policy.
///
/// `resource_server` is optional at parse time — when absent it defaults to
/// unconfigured. Call
/// [`resolve_all`](AccessTokenSubstrateConfigSource::resolve_all) with the OIDC
/// shared defaults to produce a [`ResolvedAccessTokenSubstrateConfig`].
#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Deserialize, Default)]
pub struct AccessTokenSubstrateConfig {
    /// OAuth resource-server verifier configuration.
    #[serde(default, flatten)]
    pub resource_server: OAuthResourceServerConfig,

    /// Token propagation capability axis.
    #[serde(default)]
    pub token_propagation: TokenPropagation,
}

impl AccessTokenSubstrateConfigSource for AccessTokenSubstrateConfig {
    fn resource_server_config(&self) -> &OAuthResourceServerConfig {
        &self.resource_server
    }

    fn token_propagation(&self) -> &TokenPropagation {
        &self.token_propagation
    }
}

// ---------------------------------------------------------------------------
// Resolved (validated) config
// ---------------------------------------------------------------------------

/// Validated configuration bundle for the access-token substrate.
///
/// Produced by [`AccessTokenSubstrateConfigSource::resolve_all`]. The
/// `resource_server` field has had OIDC shared defaults applied and passed
/// validation.
#[derive(Debug, Clone)]
pub struct ResolvedAccessTokenSubstrateConfig {
    /// OAuth resource-server config with shared defaults applied.
    pub resource_server: OAuthResourceServerConfig,
    /// Token propagation capability axis (pass-through, no extra validation).
    pub token_propagation: TokenPropagation,
}

#[cfg(test)]
mod tests {
    use std::sync::{
        Arc,
        atomic::{AtomicUsize, Ordering},
    };

    use securitydept_oauth_provider::{OAuthProviderRemoteConfig, OidcSharedConfig};
    use securitydept_oauth_resource_server::OAuthResourceServerIntrospectionConfig;
    use securitydept_utils::secret::SecretString;

    use super::*;

    #[test]
    fn resolve_all_inherits_shared_defaults() {
        let shared = OidcSharedConfig {
            remote: OAuthProviderRemoteConfig {
                well_known_url: Some(
                    "https://auth.example.com/.well-known/openid-configuration".to_string(),
                ),
                ..Default::default()
            },
            client_id: Some("shared-app".to_string()),
            client_secret: Some(SecretString::from("shared-secret")),
            ..Default::default()
        };

        let raw = AccessTokenSubstrateConfig {
            resource_server: OAuthResourceServerConfig {
                introspection: Some(OAuthResourceServerIntrospectionConfig::default()),
                ..Default::default()
            },
            ..Default::default()
        };

        let resolved = raw.resolve_all(Some(&shared)).expect("should resolve");
        assert_eq!(
            resolved
                .resource_server
                .introspection
                .as_ref()
                .unwrap()
                .client_id
                .as_deref(),
            Some("shared-app"),
            "introspection.client_id should inherit from [oidc]"
        );
    }

    #[test]
    fn resolve_all_without_shared_returns_raw() {
        let raw = AccessTokenSubstrateConfig {
            resource_server: OAuthResourceServerConfig::default(),
            token_propagation: TokenPropagation::Disabled,
        };

        let resolved = raw
            .resolve_all(None)
            .expect("should resolve without shared");
        assert!(
            resolved.resource_server.remote.well_known_url.is_none(),
            "no shared defaults should be applied"
        );
        assert!(matches!(
            resolved.token_propagation,
            TokenPropagation::Disabled
        ));
    }

    #[test]
    fn resolve_all_propagation_axis_passes_through() {
        use crate::access_token_substrate::propagation::{
            PropagationDestinationPolicy, TokenPropagatorConfig,
        };

        let raw = AccessTokenSubstrateConfig {
            resource_server: OAuthResourceServerConfig::default(),
            token_propagation: TokenPropagation::Enabled {
                config: TokenPropagatorConfig {
                    destination_policy: PropagationDestinationPolicy {
                        allowed_targets: vec![],
                        ..Default::default()
                    },
                    ..Default::default()
                },
            },
        };

        let resolved = raw.resolve_all(None).expect("should resolve");
        assert!(matches!(
            resolved.token_propagation,
            TokenPropagation::Enabled { .. }
        ));
    }

    #[test]
    fn resolve_all_with_validator_rejects_raw_config() {
        struct RejectEnabledPropagation;

        impl AccessTokenSubstrateConfigValidator for RejectEnabledPropagation {
            fn validate_raw_access_token_substrate_config(
                &self,
                config: &AccessTokenSubstrateConfig,
            ) -> Result<(), AccessTokenSubstrateConfigValidationError> {
                if matches!(config.token_propagation, TokenPropagation::Enabled { .. }) {
                    return Err(AccessTokenSubstrateConfigValidationError::new(
                        "token_propagation",
                        "disabled_by_host",
                        "token propagation is disabled by the host",
                    ));
                }

                Ok(())
            }
        }

        use crate::access_token_substrate::propagation::TokenPropagatorConfig;

        let raw = AccessTokenSubstrateConfig {
            token_propagation: TokenPropagation::Enabled {
                config: TokenPropagatorConfig::default(),
            },
            ..Default::default()
        };

        let error = raw
            .resolve_all_with_validator(None, &RejectEnabledPropagation)
            .expect_err("validator should reject enabled propagation");

        assert!(matches!(
            error,
            BackendConfigError::AccessTokenSubstrateValidation(ref validation)
                if validation.field_path == "token_propagation"
                    && validation.code == "disabled_by_host"
        ));
    }

    #[test]
    fn resolve_all_with_validator_accepts_validator_composition() {
        #[derive(Clone)]
        struct CountValidator(Arc<AtomicUsize>);

        impl AccessTokenSubstrateConfigValidator for CountValidator {
            fn validate_raw_access_token_substrate_config(
                &self,
                _config: &AccessTokenSubstrateConfig,
            ) -> Result<(), AccessTokenSubstrateConfigValidationError> {
                self.0.fetch_add(1, Ordering::SeqCst);
                Ok(())
            }
        }

        let calls = Arc::new(AtomicUsize::new(0));
        let validators = [
            CountValidator(Arc::clone(&calls)),
            CountValidator(Arc::clone(&calls)),
        ];
        let raw = AccessTokenSubstrateConfig::default();

        raw.resolve_all_with_validator(None, &validators)
            .expect("composed validators should pass");

        assert_eq!(calls.load(Ordering::SeqCst), 2);
    }
}