Skip to main content

appcore_api/http/
auth.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: auth.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/06/02 13:08:16 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/02 13:24:05 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Bearer-token authorization for HTTP command and query ingress.
12
13use crate::command_contract::{CommandRequest, CommandResponse};
14use crate::query_contract::{QueryRequest, QueryResponse};
15use appcore_security::CommandTokenError;
16pub use appcore_security::{
17    RequestPayloadRef, RequestValidationDetails, RequestValidationDetailsRef,
18};
19use axum::http::{HeaderMap, StatusCode};
20use axum::Json;
21use std::sync::Arc;
22
23use super::response::{command_forbidden, command_unauthorized};
24
25#[derive(Clone)]
26/// Bearer-token requirements and verifier for HTTP Runtime routes.
27pub struct HttpCommandAuth {
28    pub(crate) require_token: bool,
29    pub(crate) public_status: bool,
30    pub(crate) verifier: Option<Arc<dyn CommandTokenVerifier>>,
31}
32
33impl Default for HttpCommandAuth {
34    fn default() -> Self {
35        Self {
36            require_token: true,
37            public_status: false,
38            verifier: None,
39        }
40    }
41}
42
43impl HttpCommandAuth {
44    /// Requires command and query authentication through `verifier`.
45    pub fn required(verifier: Arc<dyn CommandTokenVerifier>) -> Self {
46        Self {
47            require_token: true,
48            public_status: false,
49            verifier: Some(verifier),
50        }
51    }
52
53    /// Controls unauthenticated access to the reduced status response.
54    pub fn with_public_status(mut self, public_status: bool) -> Self {
55        self.public_status = public_status;
56        self
57    }
58
59    /// Returns whether command and query endpoints require bearer tokens.
60    pub fn requires_token(&self) -> bool {
61        self.require_token
62    }
63
64    /// Returns whether unauthenticated callers may read reduced status.
65    pub fn allows_public_status(&self) -> bool {
66        self.public_status
67    }
68
69    /// Explicitly disables command and query authentication for local tests.
70    ///
71    /// This constructor exists only in tests or debug builds that explicitly
72    /// enable `insecure-testing`. Built-in hosts additionally reject a
73    /// non-loopback listener when this policy is selected.
74    #[cfg(any(test, feature = "insecure-testing"))]
75    pub fn insecure_local_for_testing() -> Self {
76        Self {
77            require_token: false,
78            public_status: false,
79            verifier: None,
80        }
81    }
82}
83
84/// Verifies command- and query-scoped bearer tokens.
85pub trait CommandTokenVerifier: Send + Sync {
86    /// Verifies a token for a command name.
87    fn verify_command_token(
88        &self,
89        token: &str,
90        command_name: &str,
91    ) -> Result<(), CommandTokenError>;
92    /// Verifies a token for a query name.
93    fn verify_query_token(&self, token: &str, query_name: &str) -> Result<(), CommandTokenError>;
94
95    /// Verifies a command token with optional request-bound validation details.
96    fn verify_command_token_with_request(
97        &self,
98        token: &str,
99        command_name: &str,
100        _details: Option<&RequestValidationDetails>,
101    ) -> Result<(), CommandTokenError> {
102        self.verify_command_token(token, command_name)
103    }
104
105    /// Verifies a query token with optional request-bound validation details.
106    fn verify_query_token_with_request(
107        &self,
108        token: &str,
109        query_name: &str,
110        _details: Option<&RequestValidationDetails>,
111    ) -> Result<(), CommandTokenError> {
112        self.verify_query_token(token, query_name)
113    }
114
115    /// Verifies a command token without requiring an owned payload copy.
116    ///
117    /// The default materializes the owned details and invokes
118    /// [`Self::verify_command_token_with_request`] so existing implementations
119    /// retain their request-aware behavior.
120    fn verify_command_token_with_borrowed_request(
121        &self,
122        token: &str,
123        command_name: &str,
124        details: Option<&RequestValidationDetailsRef<'_>>,
125    ) -> Result<(), CommandTokenError> {
126        let owned = owned_request_details(details)?;
127        self.verify_command_token_with_request(token, command_name, owned.as_ref())
128    }
129
130    /// Verifies a query token without requiring an owned payload copy.
131    ///
132    /// The default materializes the owned details and invokes
133    /// [`Self::verify_query_token_with_request`] so existing implementations
134    /// retain their request-aware behavior.
135    fn verify_query_token_with_borrowed_request(
136        &self,
137        token: &str,
138        query_name: &str,
139        details: Option<&RequestValidationDetailsRef<'_>>,
140    ) -> Result<(), CommandTokenError> {
141        let owned = owned_request_details(details)?;
142        self.verify_query_token_with_request(token, query_name, owned.as_ref())
143    }
144}
145
146pub(crate) fn authorize_command(
147    auth: &HttpCommandAuth,
148    headers: &HeaderMap,
149    request: &CommandRequest,
150) -> Option<(StatusCode, Json<CommandResponse>)> {
151    if !auth.require_token {
152        return None;
153    }
154    let token = match extract_bearer_token(headers) {
155        Some(token) => token,
156        None => return Some(command_unauthorized("missing bearer token")),
157    };
158    let Some(verifier) = &auth.verifier else {
159        return Some(command_unauthorized("token verifier not configured"));
160    };
161    let details = RequestValidationDetailsRef {
162        purpose: "command",
163        name: &request.command_name,
164        id: &request.command_id,
165        idempotency_key: request.idempotency_key.as_deref(),
166        payload: RequestPayloadRef::Text(&request.payload),
167        subject: None,
168        audience: None,
169    };
170    match verifier.verify_command_token_with_borrowed_request(
171        token,
172        &request.command_name,
173        Some(&details),
174    ) {
175        Ok(()) => None,
176        Err(CommandTokenError::Forbidden) => {
177            Some(command_forbidden("command not allowed for token"))
178        }
179        Err(CommandTokenError::InvalidFormat | CommandTokenError::Unauthorized) => {
180            Some(command_unauthorized("invalid bearer token"))
181        }
182    }
183}
184
185pub(crate) fn authorize_query(
186    auth: &HttpCommandAuth,
187    headers: &HeaderMap,
188    request: &QueryRequest,
189) -> Option<(StatusCode, Json<QueryResponse>)> {
190    if !auth.require_token {
191        return None;
192    }
193    let token = match extract_bearer_token(headers) {
194        Some(token) => token,
195        None => {
196            return Some((
197                StatusCode::UNAUTHORIZED,
198                Json(QueryResponse::rejected("missing bearer token")),
199            ))
200        }
201    };
202    let Some(verifier) = &auth.verifier else {
203        return Some((
204            StatusCode::UNAUTHORIZED,
205            Json(QueryResponse::rejected("token verifier not configured")),
206        ));
207    };
208    let details = RequestValidationDetailsRef {
209        purpose: "query",
210        name: &request.query_name,
211        id: &request.query_id,
212        idempotency_key: None,
213        payload: RequestPayloadRef::Json(&request.payload),
214        subject: None,
215        audience: None,
216    };
217    match verifier.verify_query_token_with_borrowed_request(
218        token,
219        &request.query_name,
220        Some(&details),
221    ) {
222        Ok(()) => None,
223        Err(CommandTokenError::Forbidden) => Some((
224            StatusCode::FORBIDDEN,
225            Json(QueryResponse::rejected("query not allowed for token")),
226        )),
227        Err(_) => Some((
228            StatusCode::UNAUTHORIZED,
229            Json(QueryResponse::rejected("invalid bearer token")),
230        )),
231    }
232}
233
234fn owned_request_details(
235    details: Option<&RequestValidationDetailsRef<'_>>,
236) -> Result<Option<RequestValidationDetails>, CommandTokenError> {
237    details
238        .copied()
239        .map(RequestValidationDetailsRef::to_owned)
240        .transpose()
241        .map_err(|_| CommandTokenError::InvalidFormat)
242}
243
244pub(crate) fn authorize_status(
245    auth: &HttpCommandAuth,
246    headers: &HeaderMap,
247) -> Result<bool, StatusCode> {
248    let token_opt = extract_bearer_token(headers);
249    if !auth.public_status {
250        let Some(token) = token_opt else {
251            return Err(StatusCode::UNAUTHORIZED);
252        };
253        let Some(verifier) = &auth.verifier else {
254            return Err(StatusCode::UNAUTHORIZED);
255        };
256        match verifier.verify_query_token_with_request(token, "runtime.status", None) {
257            Ok(()) => Ok(true),
258            Err(CommandTokenError::Forbidden) => Err(StatusCode::FORBIDDEN),
259            Err(_) => Err(StatusCode::UNAUTHORIZED),
260        }
261    } else {
262        let Some(token) = token_opt else {
263            return Ok(false);
264        };
265        let Some(verifier) = &auth.verifier else {
266            return Ok(false);
267        };
268        match verifier.verify_query_token_with_request(token, "runtime.status", None) {
269            Ok(()) => Ok(true),
270            _ => Ok(false),
271        }
272    }
273}
274
275pub(crate) fn authorize_private_status(
276    auth: &HttpCommandAuth,
277    headers: &HeaderMap,
278    query_name: &str,
279) -> Result<(), StatusCode> {
280    let Some(token) = extract_bearer_token(headers) else {
281        return Err(StatusCode::UNAUTHORIZED);
282    };
283    let Some(verifier) = &auth.verifier else {
284        return Err(StatusCode::UNAUTHORIZED);
285    };
286    match verifier.verify_query_token_with_request(token, query_name, None) {
287        Ok(()) => Ok(()),
288        Err(CommandTokenError::Forbidden) => Err(StatusCode::FORBIDDEN),
289        Err(_) => Err(StatusCode::UNAUTHORIZED),
290    }
291}
292
293fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
294    if headers.get_all("authorization").iter().count() != 1 {
295        return None;
296    }
297    let auth = headers.get("authorization")?;
298    let auth = auth.to_str().ok()?;
299    let token = auth.strip_prefix("Bearer ")?;
300    if token.is_empty() {
301        return None;
302    }
303    Some(token)
304}