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    /// Whether command and query endpoints require a bearer token.
29    pub require_token: bool,
30    /// Whether unauthenticated callers may read the reduced status response.
31    pub public_status: bool,
32    /// Token verifier used when authentication is required.
33    pub verifier: Option<Arc<dyn CommandTokenVerifier>>,
34}
35
36impl Default for HttpCommandAuth {
37    fn default() -> Self {
38        Self {
39            require_token: true,
40            public_status: false,
41            verifier: None,
42        }
43    }
44}
45
46impl HttpCommandAuth {
47    /// Explicitly disables command and query authentication for local tests.
48    ///
49    /// Production hosts should use [`Self::default`] or provide a verifier.
50    pub fn insecure_local_for_testing() -> Self {
51        Self {
52            require_token: false,
53            public_status: false,
54            verifier: None,
55        }
56    }
57}
58
59/// Verifies command- and query-scoped bearer tokens.
60pub trait CommandTokenVerifier: Send + Sync {
61    /// Verifies a token for a command name.
62    fn verify_command_token(
63        &self,
64        token: &str,
65        command_name: &str,
66    ) -> Result<(), CommandTokenError>;
67    /// Verifies a token for a query name.
68    fn verify_query_token(&self, token: &str, query_name: &str) -> Result<(), CommandTokenError>;
69
70    /// Verifies a command token with optional request-bound validation details.
71    fn verify_command_token_with_request(
72        &self,
73        token: &str,
74        command_name: &str,
75        _details: Option<&RequestValidationDetails>,
76    ) -> Result<(), CommandTokenError> {
77        self.verify_command_token(token, command_name)
78    }
79
80    /// Verifies a query token with optional request-bound validation details.
81    fn verify_query_token_with_request(
82        &self,
83        token: &str,
84        query_name: &str,
85        _details: Option<&RequestValidationDetails>,
86    ) -> Result<(), CommandTokenError> {
87        self.verify_query_token(token, query_name)
88    }
89
90    /// Verifies a command token without requiring an owned payload copy.
91    ///
92    /// The default materializes the owned details and invokes
93    /// [`Self::verify_command_token_with_request`] so existing implementations
94    /// retain their request-aware behavior.
95    fn verify_command_token_with_borrowed_request(
96        &self,
97        token: &str,
98        command_name: &str,
99        details: Option<&RequestValidationDetailsRef<'_>>,
100    ) -> Result<(), CommandTokenError> {
101        let owned = owned_request_details(details)?;
102        self.verify_command_token_with_request(token, command_name, owned.as_ref())
103    }
104
105    /// Verifies a query token without requiring an owned payload copy.
106    ///
107    /// The default materializes the owned details and invokes
108    /// [`Self::verify_query_token_with_request`] so existing implementations
109    /// retain their request-aware behavior.
110    fn verify_query_token_with_borrowed_request(
111        &self,
112        token: &str,
113        query_name: &str,
114        details: Option<&RequestValidationDetailsRef<'_>>,
115    ) -> Result<(), CommandTokenError> {
116        let owned = owned_request_details(details)?;
117        self.verify_query_token_with_request(token, query_name, owned.as_ref())
118    }
119}
120
121pub(crate) fn authorize_command(
122    auth: &HttpCommandAuth,
123    headers: &HeaderMap,
124    request: &CommandRequest,
125) -> Option<(StatusCode, Json<CommandResponse>)> {
126    if !auth.require_token {
127        return None;
128    }
129    let token = match extract_bearer_token(headers) {
130        Some(token) => token,
131        None => return Some(command_unauthorized("missing bearer token")),
132    };
133    let Some(verifier) = &auth.verifier else {
134        return Some(command_unauthorized("token verifier not configured"));
135    };
136    let details = RequestValidationDetailsRef {
137        purpose: "command",
138        name: &request.command_name,
139        id: &request.command_id,
140        idempotency_key: request.idempotency_key.as_deref(),
141        payload: RequestPayloadRef::Text(&request.payload),
142        subject: None,
143        audience: None,
144    };
145    match verifier.verify_command_token_with_borrowed_request(
146        token,
147        &request.command_name,
148        Some(&details),
149    ) {
150        Ok(()) => None,
151        Err(CommandTokenError::Forbidden) => {
152            Some(command_forbidden("command not allowed for token"))
153        }
154        Err(CommandTokenError::InvalidFormat | CommandTokenError::Unauthorized) => {
155            Some(command_unauthorized("invalid bearer token"))
156        }
157    }
158}
159
160pub(crate) fn authorize_query(
161    auth: &HttpCommandAuth,
162    headers: &HeaderMap,
163    request: &QueryRequest,
164) -> Option<(StatusCode, Json<QueryResponse>)> {
165    if !auth.require_token {
166        return None;
167    }
168    let token = match extract_bearer_token(headers) {
169        Some(token) => token,
170        None => {
171            return Some((
172                StatusCode::UNAUTHORIZED,
173                Json(QueryResponse::rejected("missing bearer token")),
174            ))
175        }
176    };
177    let Some(verifier) = &auth.verifier else {
178        return Some((
179            StatusCode::UNAUTHORIZED,
180            Json(QueryResponse::rejected("token verifier not configured")),
181        ));
182    };
183    let details = RequestValidationDetailsRef {
184        purpose: "query",
185        name: &request.query_name,
186        id: &request.query_id,
187        idempotency_key: None,
188        payload: RequestPayloadRef::Json(&request.payload),
189        subject: None,
190        audience: None,
191    };
192    match verifier.verify_query_token_with_borrowed_request(
193        token,
194        &request.query_name,
195        Some(&details),
196    ) {
197        Ok(()) => None,
198        Err(CommandTokenError::Forbidden) => Some((
199            StatusCode::FORBIDDEN,
200            Json(QueryResponse::rejected("query not allowed for token")),
201        )),
202        Err(_) => Some((
203            StatusCode::UNAUTHORIZED,
204            Json(QueryResponse::rejected("invalid bearer token")),
205        )),
206    }
207}
208
209fn owned_request_details(
210    details: Option<&RequestValidationDetailsRef<'_>>,
211) -> Result<Option<RequestValidationDetails>, CommandTokenError> {
212    details
213        .copied()
214        .map(RequestValidationDetailsRef::to_owned)
215        .transpose()
216        .map_err(|_| CommandTokenError::InvalidFormat)
217}
218
219pub(crate) fn authorize_status(
220    auth: &HttpCommandAuth,
221    headers: &HeaderMap,
222) -> Result<bool, StatusCode> {
223    let token_opt = extract_bearer_token(headers);
224    if !auth.public_status {
225        let Some(token) = token_opt else {
226            return Err(StatusCode::UNAUTHORIZED);
227        };
228        let Some(verifier) = &auth.verifier else {
229            return Err(StatusCode::UNAUTHORIZED);
230        };
231        match verifier.verify_query_token_with_request(token, "runtime.status", None) {
232            Ok(()) => Ok(true),
233            Err(CommandTokenError::Forbidden) => Err(StatusCode::FORBIDDEN),
234            Err(_) => Err(StatusCode::UNAUTHORIZED),
235        }
236    } else {
237        let Some(token) = token_opt else {
238            return Ok(false);
239        };
240        let Some(verifier) = &auth.verifier else {
241            return Ok(false);
242        };
243        match verifier.verify_query_token_with_request(token, "runtime.status", None) {
244            Ok(()) => Ok(true),
245            _ => Ok(false),
246        }
247    }
248}
249
250pub(crate) fn authorize_private_status(
251    auth: &HttpCommandAuth,
252    headers: &HeaderMap,
253    query_name: &str,
254) -> Result<(), StatusCode> {
255    let Some(token) = extract_bearer_token(headers) else {
256        return Err(StatusCode::UNAUTHORIZED);
257    };
258    let Some(verifier) = &auth.verifier else {
259        return Err(StatusCode::UNAUTHORIZED);
260    };
261    match verifier.verify_query_token_with_request(token, query_name, None) {
262        Ok(()) => Ok(()),
263        Err(CommandTokenError::Forbidden) => Err(StatusCode::FORBIDDEN),
264        Err(_) => Err(StatusCode::UNAUTHORIZED),
265    }
266}
267
268fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
269    if headers.get_all("authorization").iter().count() != 1 {
270        return None;
271    }
272    let auth = headers.get("authorization")?;
273    let auth = auth.to_str().ok()?;
274    let token = auth.strip_prefix("Bearer ")?;
275    if token.is_empty() {
276        return None;
277    }
278    Some(token)
279}