Skip to main content

ito_backend/
auth.rs

1//! Multi-tenant bearer token authentication for the backend API.
2//!
3//! Two token tiers are supported:
4//!
5//! - **Admin tokens**: authorize access to any project namespace.
6//! - **Derived project tokens**: authorize exactly one `{org}/{repo}`,
7//!   computed as `HMAC-SHA256(token_seed, "{org}/{repo}")`.
8//!
9//! Health and readiness endpoints bypass authentication.
10
11use axum::{
12    extract::{Request, State},
13    http::header,
14    middleware::Next,
15    response::{IntoResponse, Response},
16};
17use hmac::{Hmac, Mac};
18use serde::Serialize;
19use sha2::Sha256;
20use std::sync::Arc;
21
22use crate::error::ApiErrorResponse;
23use crate::state::AppState;
24
25type HmacSha256 = Hmac<Sha256>;
26
27/// Paths that bypass authentication entirely.
28const EXEMPT_PATHS: &[&str] = &["/api/v1/health", "/api/v1/ready"];
29
30/// Derive a per-project token from a seed and a project key.
31///
32/// The project key is `"{org}/{repo}"`. The token is the lowercase hex
33/// representation of `HMAC-SHA256(seed, project_key)`.
34pub fn derive_project_token(seed: &str, org: &str, repo: &str) -> String {
35    let project_key = format!("{org}/{repo}");
36    let mut mac = HmacSha256::new_from_slice(seed.as_bytes()).expect("HMAC accepts any key length");
37    mac.update(project_key.as_bytes());
38    let result = mac.finalize();
39    hex::encode(result.into_bytes())
40}
41
42/// Constant-time byte comparison.
43fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
44    if a.len() != b.len() {
45        return false;
46    }
47    let mut diff: u8 = 0;
48    for (x, y) in a.iter().zip(b.iter()) {
49        diff |= x ^ y;
50    }
51    diff == 0
52}
53
54/// Token validation result indicating which tier matched.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56#[serde(tag = "scope", rename_all = "lowercase")]
57pub enum TokenScope {
58    /// Admin token — authorized for any project.
59    Admin,
60    /// Per-project token — authorized for a specific `{org}/{repo}`.
61    Project {
62        /// Organization.
63        org: String,
64        /// Repository.
65        repo: String,
66    },
67}
68
69/// Validate a bearer token against the backend auth configuration.
70///
71/// Returns the matched [`TokenScope`] or `None` if the token is invalid.
72pub fn validate_token(state: &AppState, token: &str, org: &str, repo: &str) -> Option<TokenScope> {
73    // Check admin tokens first
74    for admin_token in &state.auth.admin_tokens {
75        if constant_time_eq(token.as_bytes(), admin_token.as_bytes()) {
76            return Some(TokenScope::Admin);
77        }
78    }
79
80    // Check derived project token
81    let Some(seed) = &state.auth.token_seed else {
82        return None;
83    };
84
85    let expected = derive_project_token(seed, org, repo);
86    if constant_time_eq(token.as_bytes(), expected.as_bytes()) {
87        return Some(TokenScope::Project {
88            org: org.to_string(),
89            repo: repo.to_string(),
90        });
91    }
92
93    None
94}
95
96/// Extract `{org}` and `{repo}` from the request path.
97///
98/// Expected prefix: `/api/v1/projects/{org}/{repo}/...`
99fn extract_org_repo(path: &str) -> Option<(&str, &str)> {
100    let rest = path.strip_prefix("/api/v1/projects/")?;
101    let (org, rest) = rest.split_once('/')?;
102    // repo is the next segment (until next `/` or end)
103    let repo = rest.split('/').next()?;
104    if org.is_empty() || repo.is_empty() {
105        return None;
106    }
107    Some((org, repo))
108}
109
110/// Axum middleware that enforces multi-tenant bearer token authentication.
111///
112/// Health and readiness endpoints are exempt. All project-scoped routes
113/// must include a valid `Authorization: Bearer <token>` header.
114///
115/// Allowlist checks run before token validation.
116pub async fn auth_middleware(
117    State(state): State<Arc<AppState>>,
118    mut request: Request,
119    next: Next,
120) -> Response {
121    let path = request.uri().path();
122    let normalized_path = path.trim_end_matches('/');
123
124    // Exempt health and readiness endpoints
125    for exempt in EXEMPT_PATHS {
126        if normalized_path == exempt.trim_end_matches('/') {
127            return next.run(request).await;
128        }
129    }
130
131    // Extract org/repo from the path
132    let Some((org, repo)) = extract_org_repo(path) else {
133        // Non-project routes that aren't exempt are also passed through
134        // (e.g., unknown paths will 404 via the router)
135        return next.run(request).await;
136    };
137
138    // Enforce allowlist before token check
139    if !state.allowlist.is_allowed(org, repo) {
140        return ApiErrorResponse::forbidden(format!(
141            "Organization/repository '{org}/{repo}' is not allowed"
142        ))
143        .into_response();
144    }
145
146    // Extract bearer token
147    let bearer_token = request
148        .headers()
149        .get(header::AUTHORIZATION)
150        .and_then(|v| v.to_str().ok())
151        .and_then(|v| v.strip_prefix("Bearer "));
152
153    let Some(token) = bearer_token else {
154        return ApiErrorResponse::unauthorized("Missing bearer token").into_response();
155    };
156
157    let Some(scope) = validate_token(&state, token, org, repo) else {
158        return ApiErrorResponse::unauthorized("Invalid bearer token").into_response();
159    };
160
161    // Store the token scope in request extensions for downstream handlers
162    request.extensions_mut().insert(scope);
163
164    next.run(request).await
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use ito_core::fs_project_store::FsBackendProjectStore;
171
172    fn test_app_state(auth: ito_config::types::BackendAuthConfig) -> AppState {
173        let store = Arc::new(FsBackendProjectStore::new("/data"));
174        AppState::new(
175            std::path::PathBuf::from("/data"),
176            store,
177            ito_config::types::BackendAllowlistConfig::default(),
178            auth,
179        )
180    }
181
182    #[test]
183    fn derive_project_token_is_deterministic() {
184        let t1 = derive_project_token("secret", "acme", "repo1");
185        let t2 = derive_project_token("secret", "acme", "repo1");
186        assert_eq!(t1, t2);
187    }
188
189    #[test]
190    fn derive_project_token_differs_by_project() {
191        let t1 = derive_project_token("secret", "acme", "repo1");
192        let t2 = derive_project_token("secret", "acme", "repo2");
193        assert_ne!(t1, t2);
194    }
195
196    #[test]
197    fn derive_project_token_differs_by_seed() {
198        let t1 = derive_project_token("seed1", "acme", "repo1");
199        let t2 = derive_project_token("seed2", "acme", "repo1");
200        assert_ne!(t1, t2);
201    }
202
203    #[test]
204    fn derive_project_token_is_64_hex_chars() {
205        let token = derive_project_token("secret", "org", "repo");
206        assert_eq!(token.len(), 64);
207        assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
208    }
209
210    #[test]
211    fn extract_org_repo_valid_path() {
212        let result = extract_org_repo("/api/v1/projects/acme/infra/changes");
213        assert_eq!(result, Some(("acme", "infra")));
214    }
215
216    #[test]
217    fn extract_org_repo_no_trailing() {
218        let result = extract_org_repo("/api/v1/projects/acme/infra");
219        assert_eq!(result, Some(("acme", "infra")));
220    }
221
222    #[test]
223    fn extract_org_repo_non_project_path() {
224        let result = extract_org_repo("/api/v1/health");
225        assert!(result.is_none());
226    }
227
228    #[test]
229    fn validate_token_admin_matches() {
230        let state = test_app_state(ito_config::types::BackendAuthConfig {
231            admin_tokens: vec!["admin-secret".to_string()],
232            token_seed: None,
233        });
234        let result = validate_token(&state, "admin-secret", "any", "project");
235        assert_eq!(result, Some(TokenScope::Admin));
236    }
237
238    #[test]
239    fn validate_token_project_matches() {
240        let state = test_app_state(ito_config::types::BackendAuthConfig {
241            admin_tokens: vec![],
242            token_seed: Some("my-seed".to_string()),
243        });
244        let expected_token = derive_project_token("my-seed", "acme", "repo1");
245        let result = validate_token(&state, &expected_token, "acme", "repo1");
246        assert_eq!(
247            result,
248            Some(TokenScope::Project {
249                org: "acme".to_string(),
250                repo: "repo1".to_string(),
251            })
252        );
253    }
254
255    #[test]
256    fn validate_token_wrong_project_fails() {
257        let state = test_app_state(ito_config::types::BackendAuthConfig {
258            admin_tokens: vec![],
259            token_seed: Some("my-seed".to_string()),
260        });
261        let token = derive_project_token("my-seed", "acme", "repo1");
262        // Try to use token for a different project
263        let result = validate_token(&state, &token, "acme", "repo2");
264        assert!(result.is_none());
265    }
266
267    #[test]
268    fn validate_token_invalid_fails() {
269        let state = test_app_state(ito_config::types::BackendAuthConfig {
270            admin_tokens: vec!["admin".to_string()],
271            token_seed: Some("seed".to_string()),
272        });
273        let result = validate_token(&state, "bogus-token", "acme", "repo1");
274        assert!(result.is_none());
275    }
276
277    #[test]
278    fn exempt_paths_are_health_and_ready() {
279        assert!(EXEMPT_PATHS.contains(&"/api/v1/health"));
280        assert!(EXEMPT_PATHS.contains(&"/api/v1/ready"));
281    }
282
283    #[test]
284    fn token_scope_serializes_admin() {
285        let scope = TokenScope::Admin;
286        let json = serde_json::to_value(&scope).unwrap();
287        assert_eq!(json["scope"], "admin");
288    }
289
290    #[test]
291    fn token_scope_serializes_project() {
292        let scope = TokenScope::Project {
293            org: "acme".to_string(),
294            repo: "infra".to_string(),
295        };
296        let json = serde_json::to_value(&scope).unwrap();
297        assert_eq!(json["scope"], "project");
298        assert_eq!(json["org"], "acme");
299        assert_eq!(json["repo"], "infra");
300    }
301}