Skip to main content

authn_resolver/domain/
service.rs

1//! Domain service for the `AuthN` resolver.
2//!
3//! Plugin discovery is lazy: resolved on first API call after
4//! types-registry is ready.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use authn_resolver_sdk::{
10    AuthNResolverPluginClient, AuthNResolverPluginSpecV1, AuthenticationResult,
11    ClientCredentialsRequest,
12};
13use modkit::client_hub::{ClientHub, ClientScope};
14use modkit::plugins::{GtsPluginSelector, choose_plugin_instance};
15use modkit::telemetry::ThrottledLog;
16use modkit_macros::domain_model;
17use tracing::info;
18use types_registry_sdk::{ListQuery, TypesRegistryClient};
19
20use super::error::DomainError;
21
22/// Throttle interval for unavailable plugin warnings.
23const UNAVAILABLE_LOG_THROTTLE: Duration = Duration::from_secs(10);
24
25/// `AuthN` resolver service.
26///
27/// Discovers plugins via types-registry and delegates authentication calls.
28#[domain_model]
29pub struct Service {
30    hub: Arc<ClientHub>,
31    vendor: String,
32    selector: GtsPluginSelector,
33    unavailable_log_throttle: ThrottledLog,
34}
35
36impl Service {
37    /// Creates a new service with lazy plugin resolution.
38    #[must_use]
39    pub fn new(hub: Arc<ClientHub>, vendor: String) -> Self {
40        Self {
41            hub,
42            vendor,
43            selector: GtsPluginSelector::new(),
44            unavailable_log_throttle: ThrottledLog::new(UNAVAILABLE_LOG_THROTTLE),
45        }
46    }
47
48    /// Lazily resolves and returns the plugin client.
49    async fn get_plugin(&self) -> Result<Arc<dyn AuthNResolverPluginClient>, DomainError> {
50        let instance_id = self.selector.get_or_init(|| self.resolve_plugin()).await?;
51        let scope = ClientScope::gts_id(instance_id.as_ref());
52
53        if let Some(client) = self
54            .hub
55            .try_get_scoped::<dyn AuthNResolverPluginClient>(&scope)
56        {
57            Ok(client)
58        } else {
59            if self.unavailable_log_throttle.should_log() {
60                tracing::warn!(
61                    plugin_gts_id = %instance_id,
62                    vendor = %self.vendor,
63                    "Plugin client not registered yet"
64                );
65            }
66            Err(DomainError::PluginUnavailable {
67                gts_id: instance_id.to_string(),
68                reason: "client not registered yet".into(),
69            })
70        }
71    }
72
73    /// Resolves the plugin instance from types-registry.
74    #[tracing::instrument(skip_all, fields(vendor = %self.vendor))]
75    async fn resolve_plugin(&self) -> Result<String, DomainError> {
76        info!("Resolving authn_resolver plugin");
77
78        let registry = self
79            .hub
80            .get::<dyn TypesRegistryClient>()
81            .map_err(|e| DomainError::TypesRegistryUnavailable(e.to_string()))?;
82
83        let plugin_type_id = AuthNResolverPluginSpecV1::gts_schema_id().clone();
84
85        let instances = registry
86            .list(
87                ListQuery::new()
88                    .with_pattern(format!("{plugin_type_id}*"))
89                    .with_is_type(false),
90            )
91            .await?;
92
93        let gts_id = choose_plugin_instance::<AuthNResolverPluginSpecV1>(
94            &self.vendor,
95            instances.iter().map(|e| (e.gts_id.as_str(), &e.content)),
96        )?;
97        info!(plugin_gts_id = %gts_id, "Selected authn_resolver plugin instance");
98
99        Ok(gts_id)
100    }
101
102    /// Authenticate a bearer token via the selected plugin.
103    ///
104    /// # Errors
105    ///
106    /// - `Unauthorized` if the token is invalid
107    /// - Plugin resolution errors
108    #[tracing::instrument(skip_all)]
109    pub async fn authenticate(
110        &self,
111        bearer_token: &str,
112    ) -> Result<AuthenticationResult, DomainError> {
113        let plugin = self.get_plugin().await?;
114        plugin
115            .authenticate(bearer_token)
116            .await
117            .map_err(DomainError::from)
118    }
119
120    /// Exchange client credentials for a `SecurityContext` via the selected plugin.
121    ///
122    /// # Errors
123    ///
124    /// - `TokenAcquisitionFailed` if credentials are invalid
125    /// - Plugin resolution errors
126    #[tracing::instrument(skip_all, fields(client_id = %request.client_id))]
127    pub async fn exchange_client_credentials(
128        &self,
129        request: &ClientCredentialsRequest,
130    ) -> Result<AuthenticationResult, DomainError> {
131        let plugin = self.get_plugin().await?;
132        plugin
133            .exchange_client_credentials(request)
134            .await
135            .map_err(DomainError::from)
136    }
137}