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::RequestValidationDetails;
17use axum::http::{HeaderMap, StatusCode};
18use axum::Json;
19use std::sync::Arc;
20
21use super::response::{command_forbidden, command_unauthorized};
22
23#[derive(Clone)]
24/// Bearer-token requirements and verifier for HTTP Runtime routes.
25pub struct HttpCommandAuth {
26    /// Whether command and query endpoints require a bearer token.
27    pub require_token: bool,
28    /// Whether unauthenticated callers may read the reduced status response.
29    pub public_status: bool,
30    /// Token verifier used when authentication is required.
31    pub verifier: Option<Arc<dyn CommandTokenVerifier>>,
32}
33
34impl Default for HttpCommandAuth {
35    fn default() -> Self {
36        Self {
37            require_token: true,
38            public_status: false,
39            verifier: None,
40        }
41    }
42}
43
44impl HttpCommandAuth {
45    /// Explicitly disables command and query authentication for local tests.
46    ///
47    /// Production hosts should use [`Self::default`] or provide a verifier.
48    pub fn insecure_local_for_testing() -> Self {
49        Self {
50            require_token: false,
51            public_status: false,
52            verifier: None,
53        }
54    }
55}
56
57/// Verifies command- and query-scoped bearer tokens.
58pub trait CommandTokenVerifier: Send + Sync {
59    /// Verifies a token for a command name.
60    fn verify_command_token(
61        &self,
62        token: &str,
63        command_name: &str,
64    ) -> Result<(), CommandTokenError>;
65    /// Verifies a token for a query name.
66    fn verify_query_token(&self, token: &str, query_name: &str) -> Result<(), CommandTokenError>;
67
68    /// Verifies a command token with optional request-bound validation details.
69    fn verify_command_token_with_request(
70        &self,
71        token: &str,
72        command_name: &str,
73        _details: Option<&RequestValidationDetails>,
74    ) -> Result<(), CommandTokenError> {
75        self.verify_command_token(token, command_name)
76    }
77
78    /// Verifies a query token with optional request-bound validation details.
79    fn verify_query_token_with_request(
80        &self,
81        token: &str,
82        query_name: &str,
83        _details: Option<&RequestValidationDetails>,
84    ) -> Result<(), CommandTokenError> {
85        self.verify_query_token(token, query_name)
86    }
87}
88
89pub(crate) fn authorize_command(
90    auth: &HttpCommandAuth,
91    headers: &HeaderMap,
92    request: &CommandRequest,
93) -> Option<(StatusCode, Json<CommandResponse>)> {
94    if !auth.require_token {
95        return None;
96    }
97    let token = match extract_bearer_token(headers) {
98        Some(token) => token,
99        None => return Some(command_unauthorized("missing bearer token")),
100    };
101    let Some(verifier) = &auth.verifier else {
102        return Some(command_unauthorized("token verifier not configured"));
103    };
104    let details = RequestValidationDetails {
105        purpose: "command".to_string(),
106        name: request.command_name.clone(),
107        id: request.command_id.clone(),
108        idempotency_key: request.idempotency_key.clone(),
109        payload: request.payload.clone(),
110        subject: None,
111        audience: None,
112    };
113    match verifier.verify_command_token_with_request(token, &request.command_name, Some(&details)) {
114        Ok(()) => None,
115        Err(CommandTokenError::Forbidden) => {
116            Some(command_forbidden("command not allowed for token"))
117        }
118        Err(CommandTokenError::InvalidFormat | CommandTokenError::Unauthorized) => {
119            Some(command_unauthorized("invalid bearer token"))
120        }
121    }
122}
123
124pub(crate) fn authorize_query(
125    auth: &HttpCommandAuth,
126    headers: &HeaderMap,
127    request: &QueryRequest,
128) -> Option<(StatusCode, Json<QueryResponse>)> {
129    if !auth.require_token {
130        return None;
131    }
132    let token = match extract_bearer_token(headers) {
133        Some(token) => token,
134        None => {
135            return Some((
136                StatusCode::UNAUTHORIZED,
137                Json(QueryResponse::rejected("missing bearer token")),
138            ))
139        }
140    };
141    let Some(verifier) = &auth.verifier else {
142        return Some((
143            StatusCode::UNAUTHORIZED,
144            Json(QueryResponse::rejected("token verifier not configured")),
145        ));
146    };
147    let details = RequestValidationDetails {
148        purpose: "query".to_string(),
149        name: request.query_name.clone(),
150        id: request.query_id.clone(),
151        idempotency_key: None,
152        payload: serde_json::to_string(&request.payload).unwrap_or_default(),
153        subject: None,
154        audience: None,
155    };
156    match verifier.verify_query_token_with_request(token, &request.query_name, Some(&details)) {
157        Ok(()) => None,
158        Err(CommandTokenError::Forbidden) => Some((
159            StatusCode::FORBIDDEN,
160            Json(QueryResponse::rejected("query not allowed for token")),
161        )),
162        Err(_) => Some((
163            StatusCode::UNAUTHORIZED,
164            Json(QueryResponse::rejected("invalid bearer token")),
165        )),
166    }
167}
168
169pub(crate) fn authorize_status(
170    auth: &HttpCommandAuth,
171    headers: &HeaderMap,
172) -> Result<bool, StatusCode> {
173    let token_opt = extract_bearer_token(headers);
174    if !auth.public_status {
175        let Some(token) = token_opt else {
176            return Err(StatusCode::UNAUTHORIZED);
177        };
178        let Some(verifier) = &auth.verifier else {
179            return Err(StatusCode::UNAUTHORIZED);
180        };
181        match verifier.verify_query_token_with_request(token, "runtime.status", None) {
182            Ok(()) => Ok(true),
183            Err(CommandTokenError::Forbidden) => Err(StatusCode::FORBIDDEN),
184            Err(_) => Err(StatusCode::UNAUTHORIZED),
185        }
186    } else {
187        let Some(token) = token_opt else {
188            return Ok(false);
189        };
190        let Some(verifier) = &auth.verifier else {
191            return Ok(false);
192        };
193        match verifier.verify_query_token_with_request(token, "runtime.status", None) {
194            Ok(()) => Ok(true),
195            _ => Ok(false),
196        }
197    }
198}
199
200pub(crate) fn authorize_private_status(
201    auth: &HttpCommandAuth,
202    headers: &HeaderMap,
203    query_name: &str,
204) -> Result<(), StatusCode> {
205    let Some(token) = extract_bearer_token(headers) else {
206        return Err(StatusCode::UNAUTHORIZED);
207    };
208    let Some(verifier) = &auth.verifier else {
209        return Err(StatusCode::UNAUTHORIZED);
210    };
211    match verifier.verify_query_token_with_request(token, query_name, None) {
212        Ok(()) => Ok(()),
213        Err(CommandTokenError::Forbidden) => Err(StatusCode::FORBIDDEN),
214        Err(_) => Err(StatusCode::UNAUTHORIZED),
215    }
216}
217
218fn extract_bearer_token(headers: &HeaderMap) -> Option<&str> {
219    if headers.get_all("authorization").iter().count() != 1 {
220        return None;
221    }
222    let auth = headers.get("authorization")?;
223    let auth = auth.to_str().ok()?;
224    let token = auth.strip_prefix("Bearer ")?;
225    if token.is_empty() {
226        return None;
227    }
228    Some(token)
229}