Skip to main content

soaprs_auth/
registry.rs

1//! Type-safe routing across authentication strategies with one principal type.
2
3use std::collections::BTreeMap;
4
5use soaprs_core::{BoxFuture, SoapError, SoapResult};
6
7use crate::{Authentication, Authenticator, AuthorizationName, Credential};
8
9/// Named credential authenticator registered at the application composition root.
10pub trait AuthenticationStrategy<P>: Authenticator<Credential, P>
11where
12    P: Send,
13{
14    /// Returns the stable strategy identity.
15    fn strategy(&self) -> &AuthorizationName;
16}
17
18/// Deterministic strategy router without runtime output downcasting.
19pub struct AuthenticationRegistry<P> {
20    strategies: BTreeMap<AuthorizationName, Box<dyn AuthenticationStrategy<P>>>,
21}
22
23impl<P> AuthenticationRegistry<P>
24where
25    P: Send,
26{
27    /// Creates an empty strategy registry.
28    pub fn new() -> Self {
29        Self {
30            strategies: BTreeMap::new(),
31        }
32    }
33
34    /// Registers one uniquely named strategy.
35    pub fn register<S>(&mut self, strategy: S) -> SoapResult<()>
36    where
37        S: AuthenticationStrategy<P> + 'static,
38    {
39        let name = strategy.strategy().clone();
40        if self.strategies.contains_key(&name) {
41            return Err(SoapError::conflict(format!(
42                "authentication strategy `{name}` is already registered"
43            )));
44        }
45        self.strategies.insert(name, Box::new(strategy));
46        Ok(())
47    }
48
49    /// Returns the number of registered strategies.
50    pub fn len(&self) -> usize {
51        self.strategies.len()
52    }
53
54    /// Reports whether no strategies are registered.
55    pub fn is_empty(&self) -> bool {
56        self.strategies.is_empty()
57    }
58}
59
60impl<P> Default for AuthenticationRegistry<P>
61where
62    P: Send,
63{
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl<P> Authenticator<Credential, P> for AuthenticationRegistry<P>
70where
71    P: Send,
72{
73    fn authenticate(&self, credential: Credential) -> BoxFuture<'_, SoapResult<Authentication<P>>> {
74        Box::pin(async move {
75            let requested = credential.strategy().clone();
76            let Some(strategy) = self.strategies.get(&requested) else {
77                return Err(SoapError::unsupported(format!(
78                    "authentication strategy `{requested}` is not registered"
79                )));
80            };
81            let authentication = strategy.authenticate(credential).await?;
82            if authentication.strategy() != &requested {
83                return Err(SoapError::infrastructure(
84                    "authentication strategy returned a mismatched strategy identity",
85                ));
86            }
87            Ok(authentication)
88        })
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use std::{
95        future::Future,
96        task::{Context, Poll, Waker},
97    };
98
99    use soaprs_core::{BoxFuture, SoapError, SoapErrorKind, SoapResult};
100
101    use super::{AuthenticationRegistry, AuthenticationStrategy};
102    use crate::{Authentication, Authenticator, AuthorizationName, Credential, StandardPrincipal};
103
104    struct FixedStrategy {
105        name: AuthorizationName,
106    }
107
108    impl Authenticator<Credential, StandardPrincipal> for FixedStrategy {
109        fn authenticate(
110            &self,
111            credential: Credential,
112        ) -> BoxFuture<'_, SoapResult<Authentication<StandardPrincipal>>> {
113            Box::pin(async move {
114                if credential.secret().expose_secret() != "valid" {
115                    return Err(SoapError::unauthorized());
116                }
117                Authentication::new("jwt", StandardPrincipal::new("user-1")?)
118            })
119        }
120    }
121
122    impl AuthenticationStrategy<StandardPrincipal> for FixedStrategy {
123        fn strategy(&self) -> &AuthorizationName {
124            &self.name
125        }
126    }
127
128    #[test]
129    fn registry_rejects_duplicates_and_routes_without_type_erasure() {
130        let Some(name) = AuthorizationName::new("jwt").ok() else {
131            panic!("valid strategy name");
132        };
133        let mut registry = AuthenticationRegistry::new();
134        assert!(
135            registry
136                .register(FixedStrategy { name: name.clone() })
137                .is_ok()
138        );
139        assert_eq!(registry.len(), 1);
140        assert!(registry.register(FixedStrategy { name }).is_err());
141
142        let valid = Credential::bearer("jwt", "valid")
143            .unwrap_or_else(|error| panic!("valid credential: {error}"));
144        let result = block_on(registry.authenticate(valid));
145        assert!(result.is_ok());
146
147        let unknown = Credential::bearer("unknown", "valid")
148            .unwrap_or_else(|error| panic!("valid credential: {error}"));
149        let result = block_on(registry.authenticate(unknown));
150        assert_eq!(
151            result.as_ref().map_err(|error| error.kind()),
152            Err(SoapErrorKind::Unsupported)
153        );
154    }
155
156    fn block_on<F>(future: F) -> F::Output
157    where
158        F: Future,
159    {
160        let mut context = Context::from_waker(Waker::noop());
161        let mut future = std::pin::pin!(future);
162        loop {
163            match future.as_mut().poll(&mut context) {
164                Poll::Ready(output) => return output,
165                Poll::Pending => std::thread::yield_now(),
166            }
167        }
168    }
169}