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}