1use std::sync::Arc;
4
5use axum::Json;
6use axum::body::Body;
7use axum::extract::State;
8use axum::http::{Request, StatusCode};
9use axum::middleware::Next;
10use axum::response::{IntoResponse, Response};
11use http_body_util::BodyExt;
12use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode};
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15
16use crate::error::ApiError;
17use crate::state::AppState;
18
19#[derive(Debug, Clone)]
20pub struct AuthenticatedAgent {
21 pub agent_id: String,
22 pub admin: bool,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct Claims {
28 pub agent_id: String,
30 #[serde(default)]
32 pub admin: bool,
33 pub exp: usize,
35}
36
37#[derive(Deserialize)]
39pub struct TokenRequest {
40 pub agent_id: String,
42 #[serde(default = "default_expiry_hours")]
44 pub expiry_hours: u64,
45}
46
47fn default_expiry_hours() -> u64 {
48 24
49}
50
51pub fn create_token(secret: &str, agent_id: &str, admin: bool, expiry_hours: u64) -> String {
53 let exp = jsonwebtoken::get_current_timestamp() as usize + (expiry_hours as usize * 3600);
54 let claims = Claims {
55 agent_id: agent_id.to_string(),
56 admin,
57 exp,
58 };
59 encode(
60 &Header::default(),
61 &claims,
62 &EncodingKey::from_secret(secret.as_bytes()),
63 )
64 .expect("JWT encoding should not fail with valid inputs")
65}
66
67pub fn validate_token(secret: &str, token: &str) -> Result<Claims, ApiError> {
69 decode::<Claims>(
70 token,
71 &DecodingKey::from_secret(secret.as_bytes()),
72 &Validation::default(),
73 )
74 .map(|data| data.claims)
75 .map_err(|e| ApiError::Unauthorized(format!("invalid token: {e}")))
76}
77
78fn extract_admin_key(request: &Request<Body>) -> Option<String> {
80 if let Some(v) = request
81 .headers()
82 .get("x-api-key")
83 .and_then(|v| v.to_str().ok())
84 {
85 return Some(v.to_string());
86 }
87 request
88 .headers()
89 .get("authorization")
90 .and_then(|v| v.to_str().ok())
91 .and_then(|v| v.strip_prefix("Bearer "))
92 .map(String::from)
93}
94
95pub async fn generate_token(
96 State(state): State<Arc<AppState>>,
97 request: Request<Body>,
98) -> Result<impl IntoResponse, ApiError> {
99 let secret = state.jwt_secret.as_deref().ok_or_else(|| {
100 ApiError::BadRequest("auth is disabled (no jwt-secret configured)".into())
101 })?;
102 match &state.admin_key {
103 None => {
104 return Err(ApiError::Forbidden(
105 "token endpoint disabled: no admin key configured".into(),
106 ));
107 }
108 Some(expected) => {
109 let provided = extract_admin_key(&request)
110 .ok_or_else(|| ApiError::Unauthorized("admin key required".into()))?;
111 if provided != *expected {
112 return Err(ApiError::Forbidden("invalid admin key".into()));
113 }
114 }
115 }
116 let body_bytes = request
117 .into_body()
118 .collect()
119 .await
120 .map_err(|e| ApiError::BadRequest(format!("failed to read body: {e}")))?
121 .to_bytes();
122 let req: TokenRequest = serde_json::from_slice(&body_bytes)
123 .map_err(|e| ApiError::BadRequest(format!("invalid JSON: {e}")))?;
124 let token = create_token(secret, &req.agent_id, false, req.expiry_hours);
125 Ok(Json(json!({ "token": token, "agent_id": req.agent_id })))
126}
127
128pub async fn auth_middleware(
130 State(state): State<Arc<AppState>>,
131 mut request: Request<Body>,
132 next: Next,
133) -> Response {
134 let secret = match &state.jwt_secret {
135 Some(s) => s.clone(),
136 None => return next.run(request).await,
138 };
139
140 let path = request.uri().path();
142 if path == "/v1/health" || path == "/v1/auth/token" {
143 return next.run(request).await;
144 }
145
146 let auth_header = request
147 .headers()
148 .get("authorization")
149 .and_then(|v| v.to_str().ok())
150 .map(String::from);
151
152 let token = match auth_header.as_deref() {
153 Some(h) if h.starts_with("Bearer ") => &h[7..],
154 _ => {
155 return (
156 StatusCode::UNAUTHORIZED,
157 Json(json!({ "error": "missing or invalid Authorization header" })),
158 )
159 .into_response();
160 }
161 };
162
163 match validate_token(&secret, token) {
164 Ok(claims) => {
165 request.extensions_mut().insert(AuthenticatedAgent {
166 agent_id: claims.agent_id,
167 admin: claims.admin,
168 });
169 next.run(request).await
170 }
171 Err(e) => e.into_response(),
172 }
173}