Skip to main content

siloxide_security/adapters/
authentication_service.rs

1//! The standard `AuthenticationService`, answered by a user-supplied backend.
2
3use std::sync::Arc;
4
5use siloxide_core::errors::{SilaError, UndefinedExecutionError, ValidationError};
6use siloxide_core::fqi::FullyQualifiedFeatureIdentifier;
7use siloxide_std::Context;
8use siloxide_std::standard::core::authentication_service::v1::{
9    AuthenticationService as GeneratedAuthenticationService, Element, LoginResponses,
10    RequestedServer, TokenLifetime, authentication_service as generated,
11};
12use siloxide_transport::invocation::handler::FeatureHandler;
13use siloxide_transport::server::snapshot::FeatureSpec;
14use uuid::Uuid;
15
16use crate::authentication::{AuthenticationBackend, LoginRejection, LoginRequest, LogoutRejection};
17use crate::secret::{AccessToken, Password};
18
19/// The Feature this adapter implements.
20pub const FEATURE: &str = generated::FEATURE;
21
22/// What a client sees when this server's own backend failed.
23///
24/// Fixed text: whether a directory was unreachable or a database was down is this server's problem,
25/// and telling a caller which is useful only to someone probing it.
26const SANITIZED: &str = "authentication is temporarily unavailable";
27
28/// Offer the standard `AuthenticationService`, backed by `backend`.
29///
30/// ```ignore
31/// let server = Server::builder(config)
32///     .add_feature(authentication_service::default_feature(MyDirectory::new()))?
33///     .build()?;
34/// ```
35#[must_use]
36pub fn default_feature<B: AuthenticationBackend>(backend: B) -> AuthenticationService {
37    AuthenticationService {
38        handler: Arc::new(generated::Handler::new(Adapter { backend })),
39    }
40}
41
42/// The standard `AuthenticationService`, ready to be registered.
43pub struct AuthenticationService {
44    handler: Arc<dyn FeatureHandler>,
45}
46
47impl std::fmt::Debug for AuthenticationService {
48    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        formatter.write_str("AuthenticationService")
50    }
51}
52
53impl From<AuthenticationService> for FeatureSpec {
54    fn from(service: AuthenticationService) -> Self {
55        FeatureSpec::from_fdl(generated::DEFINITION, service.handler)
56    }
57}
58
59/// The FQI of one of `Login`'s parameters, for a Validation Error that names what was wrong.
60fn parameter_fqi(command: &str, name: &str) -> String {
61    format!("{FEATURE}/Command/{command}/Parameter/{name}")
62}
63
64/// Report a backend failure to the caller without saying what it was.
65fn unavailable(source: &dyn std::error::Error) -> SilaError {
66    tracing::error!(error = %source, "the authentication backend failed");
67    SilaError::UndefinedExecutionError(UndefinedExecutionError {
68        message: SANITIZED.to_owned(),
69    })
70}
71
72struct Adapter<B> {
73    backend: B,
74}
75
76impl<B: AuthenticationBackend> GeneratedAuthenticationService for Adapter<B> {
77    type Error = SilaError;
78
79    async fn login(
80        &self,
81        _context: &Context,
82        user_identification: String,
83        password: String,
84        requested_server: RequestedServer,
85        requested_features: Vec<Element>,
86    ) -> Result<LoginResponses, SilaError> {
87        // The declared Pattern already rejects a malformed UUID, and the FullyQualifiedIdentifier
88        // constraint is not yet enforced by the validator, so both are parsed here rather than
89        // handed to a backend as strings it would have to re-check.
90        let requested_server =
91            Uuid::parse_str(&requested_server.into_inner()).map_err(|error| {
92                SilaError::ValidationError(ValidationError {
93                    parameter_fqi: parameter_fqi("Login", "RequestedServer"),
94                    message: format!("not a server UUID: {error}"),
95                })
96            })?;
97
98        let requested_features = requested_features
99            .into_iter()
100            .map(|raw| {
101                raw.into_inner()
102                    .parse::<FullyQualifiedFeatureIdentifier>()
103                    .map_err(|error| {
104                        SilaError::ValidationError(ValidationError {
105                            parameter_fqi: parameter_fqi("Login", "RequestedFeatures"),
106                            message: format!("not a Fully Qualified Feature Identifier: {error}"),
107                        })
108                    })
109            })
110            .collect::<Result<Vec<_>, _>>()?;
111
112        let grant = self
113            .backend
114            .login(LoginRequest {
115                user_identification,
116                password: Password::new(password),
117                requested_server,
118                requested_features,
119            })
120            .await
121            .map_err(|rejection| match rejection {
122                LoginRejection::InvalidCredentials => {
123                    generated::errors::AuthenticationFailed::raise(
124                        "the provided credentials are not valid".to_owned(),
125                    )
126                }
127                LoginRejection::Unavailable(source) => unavailable(source.as_ref()),
128            })?;
129
130        let lifetime = whole_seconds(grant.lifetime())?;
131
132        Ok(LoginResponses {
133            access_token: grant.token().reveal().to_owned(),
134            token_lifetime: lifetime,
135        })
136    }
137
138    async fn logout(&self, _context: &Context, access_token: String) -> Result<(), SilaError> {
139        self.backend
140            .logout(AccessToken::new(access_token))
141            .await
142            .map_err(|rejection| match rejection {
143                LogoutRejection::InvalidToken => generated::errors::InvalidAccessToken::raise(
144                    "the sent access token is not valid".to_owned(),
145                ),
146                LogoutRejection::Unavailable(source) => unavailable(source.as_ref()),
147            })
148    }
149}
150
151/// The lifetime as the standard Integer response, or a typed failure.
152///
153/// Part C declares `TokenLifetime` in whole seconds. Rounding a backend's `1500ms` to one second or
154/// two would tell a client something the backend did not say, so it is refused instead.
155fn whole_seconds(lifetime: std::time::Duration) -> Result<TokenLifetime, SilaError> {
156    if lifetime.subsec_nanos() != 0 {
157        return Err(SilaError::UndefinedExecutionError(
158            UndefinedExecutionError {
159                message: format!(
160                    "the authentication backend granted a token lifetime of {lifetime:?}, and the \
161                 standard TokenLifetime response carries whole seconds only"
162                ),
163            },
164        ));
165    }
166    let seconds = i64::try_from(lifetime.as_secs()).map_err(|_| {
167        SilaError::UndefinedExecutionError(UndefinedExecutionError {
168            message: format!(
169                "the authentication backend granted a token lifetime of {lifetime:?}, which does \
170                 not fit the standard TokenLifetime response"
171            ),
172        })
173    })?;
174
175    TokenLifetime::new(seconds).map_err(|error| {
176        SilaError::UndefinedExecutionError(UndefinedExecutionError {
177            message: format!("the Login token lifetime could not be represented: {error}"),
178        })
179    })
180}