1use 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
27const EXEMPT_PATHS: &[&str] = &["/api/v1/health", "/api/v1/ready"];
29
30pub 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
42fn 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56#[serde(tag = "scope", rename_all = "lowercase")]
57pub enum TokenScope {
58 Admin,
60 Project {
62 org: String,
64 repo: String,
66 },
67}
68
69pub fn validate_token(state: &AppState, token: &str, org: &str, repo: &str) -> Option<TokenScope> {
73 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 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
96fn 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 let repo = rest.split('/').next()?;
104 if org.is_empty() || repo.is_empty() {
105 return None;
106 }
107 Some((org, repo))
108}
109
110pub 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 for exempt in EXEMPT_PATHS {
126 if normalized_path == exempt.trim_end_matches('/') {
127 return next.run(request).await;
128 }
129 }
130
131 let Some((org, repo)) = extract_org_repo(path) else {
133 return next.run(request).await;
136 };
137
138 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 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 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 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}