Skip to main content

osproxy_spi/
auth.rs

1//! Authentication and authorization contracts.
2//!
3//! Separated so policy can evolve independently (`docs/02` ยง3): an
4//! [`Authenticator`] turns wire credentials into a [`Principal`]; an
5//! [`Authorizer`] decides whether that principal may perform an action. Both are
6//! provided by the implementer. The credential material itself
7//! ([`ClientCredentials`]) is consumed here and never reaches the routing SPI or
8//! telemetry (NFR-S2).
9
10use osproxy_core::{EndpointKind, ErrorCode};
11use thiserror::Error;
12
13use crate::principal::Principal;
14
15/// The raw client credentials extracted from a request by the transport.
16///
17/// Holds only what the authenticator needs; it is dropped after authentication,
18/// so the bearer token never flows downstream. The TLS slice populates
19/// [`ClientCredentials::client_cert_subject`] on mTLS termination.
20#[derive(Clone, PartialEq, Eq, Debug, Default)]
21pub struct ClientCredentials {
22    /// A bearer token from the `Authorization` header, if present.
23    pub bearer_token: Option<String>,
24    /// The verified client-certificate subject from mTLS, if the connection was
25    /// mutually authenticated.
26    pub client_cert_subject: Option<String>,
27}
28
29impl ClientCredentials {
30    /// Credentials carrying just a bearer token.
31    #[must_use]
32    pub fn bearer(token: impl Into<String>) -> Self {
33        Self {
34            bearer_token: Some(token.into()),
35            client_cert_subject: None,
36        }
37    }
38
39    /// Whether any credential is present.
40    #[must_use]
41    pub fn is_empty(&self) -> bool {
42        self.bearer_token.is_none() && self.client_cert_subject.is_none()
43    }
44}
45
46/// The action a principal is attempting, for authorization.
47#[derive(Clone, PartialEq, Eq, Debug)]
48pub struct Action {
49    /// The endpoint class being invoked.
50    pub endpoint: EndpointKind,
51    /// The logical index targeted (a name, never a value).
52    pub logical_index: String,
53}
54
55/// A failure to authenticate or authorize a request.
56#[non_exhaustive]
57#[derive(Clone, PartialEq, Eq, Debug, Error)]
58pub enum AuthError {
59    /// No credentials were presented but the deployment requires them.
60    #[error("missing credentials")]
61    MissingCredentials,
62    /// Credentials were presented but are not valid.
63    #[error("invalid credentials")]
64    InvalidCredentials,
65    /// The principal is authenticated but not permitted the action.
66    #[error("not authorized for the requested action")]
67    Unauthorized,
68}
69
70impl AuthError {
71    /// The stable [`ErrorCode`] for this failure.
72    #[must_use]
73    pub fn code(&self) -> ErrorCode {
74        match self {
75            Self::MissingCredentials | Self::InvalidCredentials => ErrorCode::AuthFailed,
76            Self::Unauthorized => ErrorCode::Unauthorized,
77        }
78    }
79
80    /// The HTTP status this failure maps to (401 vs 403).
81    #[must_use]
82    pub fn http_status(&self) -> u16 {
83        match self {
84            Self::MissingCredentials | Self::InvalidCredentials => 401,
85            Self::Unauthorized => 403,
86        }
87    }
88}
89
90/// Authenticates a client and returns the principal. mTLS and/or token.
91///
92/// Consumed through generics (no dyn on the hot path); the future must be `Send`.
93///
94/// # Examples
95///
96/// ```
97/// use osproxy_core::PrincipalId;
98/// use osproxy_spi::{Authenticator, AuthError, ClientCredentials, Principal};
99///
100/// struct AllowAnyToken;
101///
102/// impl Authenticator for AllowAnyToken {
103///     async fn authenticate(&self, creds: &ClientCredentials) -> Result<Principal, AuthError> {
104///         let token = creds.bearer_token.as_deref().ok_or(AuthError::MissingCredentials)?;
105///         Ok(Principal::new(PrincipalId::from(token)))
106///     }
107/// }
108/// ```
109pub trait Authenticator: Send + Sync + 'static {
110    /// Authenticates the credentials, returning the principal.
111    ///
112    /// # Errors
113    ///
114    /// Returns [`AuthError::MissingCredentials`] or [`AuthError::InvalidCredentials`].
115    fn authenticate(
116        &self,
117        creds: &ClientCredentials,
118    ) -> impl std::future::Future<Output = Result<Principal, AuthError>> + Send;
119}
120
121/// Authorizes a resolved request. Separate from authentication so policy can
122/// evolve independently.
123pub trait Authorizer: Send + Sync + 'static {
124    /// Decides whether `principal` may perform `action`.
125    ///
126    /// # Errors
127    ///
128    /// Returns [`AuthError::Unauthorized`] if the action is not permitted.
129    fn authorize(
130        &self,
131        principal: &Principal,
132        action: &Action,
133    ) -> impl std::future::Future<Output = Result<(), AuthError>> + Send;
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    #[test]
141    fn auth_errors_map_to_codes_and_statuses() {
142        assert_eq!(AuthError::MissingCredentials.code(), ErrorCode::AuthFailed);
143        assert_eq!(AuthError::MissingCredentials.http_status(), 401);
144        assert_eq!(AuthError::Unauthorized.code(), ErrorCode::Unauthorized);
145        assert_eq!(AuthError::Unauthorized.http_status(), 403);
146    }
147
148    #[test]
149    fn credentials_helpers() {
150        assert!(ClientCredentials::default().is_empty());
151        let c = ClientCredentials::bearer("t");
152        assert!(!c.is_empty());
153        assert_eq!(c.bearer_token.as_deref(), Some("t"));
154    }
155}