1use std::sync::Arc;
16
17use axum::http::{HeaderMap, HeaderName};
18use chrono::Utc;
19use fraiseql_core::security::{AuthenticatedUser, SecurityContext};
20use serde::Deserialize;
21use sha2::{Digest, Sha256};
22use subtle::ConstantTimeEq;
23use tracing::{debug, warn};
24
25#[derive(Debug, Clone, Deserialize)]
31pub struct ApiKeyConfig {
32 #[serde(default)]
34 pub enabled: bool,
35
36 #[serde(default = "default_header")]
38 pub header: String,
39
40 #[serde(default = "default_algorithm")]
42 pub hash_algorithm: String,
43
44 #[serde(default = "default_storage")]
46 pub storage: String,
47
48 #[serde(default, rename = "static")]
50 pub static_keys: Vec<StaticApiKeyConfig>,
51}
52
53fn default_header() -> String {
54 "x-api-key".into()
55}
56fn default_algorithm() -> String {
57 "sha256".into()
58}
59fn default_storage() -> String {
60 "env".into()
61}
62
63#[derive(Debug, Clone, Deserialize)]
65pub struct StaticApiKeyConfig {
66 pub key_hash: String,
68 #[serde(default)]
70 pub scopes: Vec<String>,
71 pub name: String,
73}
74
75#[derive(Debug, Clone)]
81struct ResolvedStaticKey {
82 hash: [u8; 32],
83 scopes: Vec<String>,
84 name: String,
85}
86
87#[derive(Debug)]
89#[non_exhaustive]
90pub enum ApiKeyResult {
91 Authenticated(Box<SecurityContext>),
93 NotPresent,
95 Invalid,
97}
98
99pub struct ApiKeyAuthenticator {
101 header_name: HeaderName,
102 static_keys: Vec<ResolvedStaticKey>,
103}
104
105impl ApiKeyAuthenticator {
106 #[must_use]
111 pub fn from_config(config: &ApiKeyConfig) -> Option<Self> {
112 if !config.enabled {
113 return None;
114 }
115
116 let header_name: HeaderName = config
117 .header
118 .parse()
119 .map_err(|e| {
120 warn!(header = %config.header, error = %e, "Invalid API key header name");
121 })
122 .ok()?;
123
124 if config.hash_algorithm != "sha256" {
125 warn!(
126 algorithm = %config.hash_algorithm,
127 "Unsupported API key hash algorithm — only sha256 is supported"
128 );
129 return None;
130 }
131
132 let mut static_keys = Vec::new();
133 for entry in &config.static_keys {
134 let hex_str = entry.key_hash.strip_prefix("sha256:").unwrap_or(&entry.key_hash);
135 match hex::decode(hex_str) {
136 Ok(bytes) if bytes.len() == 32 => {
137 let mut hash = [0u8; 32];
138 hash.copy_from_slice(&bytes);
139 static_keys.push(ResolvedStaticKey {
140 hash,
141 scopes: entry.scopes.clone(),
142 name: entry.name.clone(),
143 });
144 },
145 Ok(bytes) => {
146 warn!(
147 name = %entry.name,
148 len = bytes.len(),
149 "API key hash has wrong length (expected 32 bytes)"
150 );
151 },
152 Err(e) => {
153 warn!(
154 name = %entry.name,
155 error = %e,
156 "API key hash is not valid hex"
157 );
158 },
159 }
160 }
161
162 Some(Self {
163 header_name,
164 static_keys,
165 })
166 }
167
168 pub async fn authenticate(&self, headers: &HeaderMap) -> ApiKeyResult {
170 let raw_key = match headers.get(&self.header_name) {
171 Some(v) => match v.to_str() {
172 Ok(s) if !s.is_empty() => s,
173 _ => return ApiKeyResult::NotPresent,
174 },
175 None => return ApiKeyResult::NotPresent,
176 };
177
178 let key = raw_key
180 .strip_prefix("ApiKey ")
181 .or_else(|| raw_key.strip_prefix("apikey "))
182 .unwrap_or(raw_key);
183
184 let key_hash = sha256_hash(key.as_bytes());
185
186 for static_key in &self.static_keys {
188 if bool::from(key_hash.ct_eq(&static_key.hash)) {
189 debug!(name = %static_key.name, "API key authenticated (static)");
190 let ctx = build_security_context(&static_key.name, &static_key.scopes);
191 return ApiKeyResult::Authenticated(Box::new(ctx));
192 }
193 }
194
195 warn!("API key authentication failed: key not found");
196 ApiKeyResult::Invalid
197 }
198}
199
200impl std::fmt::Debug for ApiKeyAuthenticator {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 f.debug_struct("ApiKeyAuthenticator")
203 .field("header_name", &self.header_name)
204 .field("static_keys_count", &self.static_keys.len())
205 .finish()
206 }
207}
208
209fn sha256_hash(input: &[u8]) -> [u8; 32] {
215 let mut hasher = Sha256::new();
216 hasher.update(input);
217 let result = hasher.finalize();
218 let mut out = [0u8; 32];
219 out.copy_from_slice(&result);
220 out
221}
222
223fn build_security_context(key_name: &str, scopes: &[String]) -> SecurityContext {
225 let user = AuthenticatedUser {
226 user_id: format!("apikey:{key_name}"),
227 scopes: scopes.to_vec(),
228 expires_at: Utc::now() + chrono::Duration::hours(24),
229 };
230 SecurityContext::from_user(&user, format!("apikey-{}", uuid::Uuid::new_v4()))
231}
232
233pub fn api_key_authenticator_from_schema(
235 schema: &fraiseql_core::schema::CompiledSchema,
236) -> Option<Arc<ApiKeyAuthenticator>> {
237 let security = schema.security.as_ref()?;
238 let api_keys_val = security.additional.get("api_keys")?;
239 let config: ApiKeyConfig = serde_json::from_value(api_keys_val.clone())
240 .map_err(|e| {
241 warn!(error = %e, "Failed to parse security.api_keys config");
242 })
243 .ok()?;
244 ApiKeyAuthenticator::from_config(&config).map(Arc::new)
245}
246
247#[cfg(test)]
252mod tests {
253 #![allow(clippy::unwrap_used)] use super::*;
256
257 fn sha256_hex(input: &str) -> String {
258 hex::encode(sha256_hash(input.as_bytes()))
259 }
260
261 fn test_config(key: &str) -> ApiKeyConfig {
262 ApiKeyConfig {
263 enabled: true,
264 header: "x-api-key".into(),
265 hash_algorithm: "sha256".into(),
266 storage: "env".into(),
267 static_keys: vec![StaticApiKeyConfig {
268 key_hash: format!("sha256:{}", sha256_hex(key)),
269 scopes: vec!["read:*".into()],
270 name: "test-key".into(),
271 }],
272 }
273 }
274
275 #[tokio::test]
276 async fn valid_api_key_returns_security_context() {
277 let config = test_config("my-secret-key");
278 let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
279
280 let mut headers = HeaderMap::new();
281 headers.insert("x-api-key", "my-secret-key".parse().unwrap());
282
283 match auth.authenticate(&headers).await {
284 ApiKeyResult::Authenticated(ctx) => {
285 assert_eq!(ctx.user_id, "apikey:test-key");
286 assert_eq!(ctx.scopes, vec!["read:*".to_string()]);
287 },
288 ref other => panic!("expected Authenticated, got {other:?}"),
289 }
290 }
291
292 #[tokio::test]
293 async fn invalid_api_key_returns_invalid() {
294 let config = test_config("my-secret-key");
295 let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
296
297 let mut headers = HeaderMap::new();
298 headers.insert("x-api-key", "wrong-key".parse().unwrap());
299
300 assert!(matches!(auth.authenticate(&headers).await, ApiKeyResult::Invalid));
301 }
302
303 #[tokio::test]
304 async fn missing_api_key_returns_not_present() {
305 let config = test_config("my-secret-key");
306 let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
307
308 let headers = HeaderMap::new();
309 assert!(matches!(auth.authenticate(&headers).await, ApiKeyResult::NotPresent));
310 }
311
312 #[tokio::test]
313 async fn api_key_prefix_stripped() {
314 let config = test_config("my-secret-key");
315 let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
316
317 let mut headers = HeaderMap::new();
318 headers.insert("x-api-key", "ApiKey my-secret-key".parse().unwrap());
319
320 assert!(matches!(auth.authenticate(&headers).await, ApiKeyResult::Authenticated(_)));
321 }
322
323 #[test]
324 fn disabled_config_returns_none() {
325 let mut config = test_config("key");
326 config.enabled = false;
327 assert!(ApiKeyAuthenticator::from_config(&config).is_none());
328 }
329
330 #[test]
331 fn invalid_hash_hex_is_skipped() {
332 let config = ApiKeyConfig {
333 enabled: true,
334 header: "x-api-key".into(),
335 hash_algorithm: "sha256".into(),
336 storage: "env".into(),
337 static_keys: vec![StaticApiKeyConfig {
338 key_hash: "not-valid-hex".into(),
339 scopes: vec![],
340 name: "bad-key".into(),
341 }],
342 };
343 let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
344 assert_eq!(auth.static_keys.len(), 0);
345 }
346
347 #[test]
348 fn hash_without_prefix_works() {
349 let hash = sha256_hex("test");
350 let config = ApiKeyConfig {
351 enabled: true,
352 header: "x-api-key".into(),
353 hash_algorithm: "sha256".into(),
354 storage: "env".into(),
355 static_keys: vec![StaticApiKeyConfig {
356 key_hash: hash, scopes: vec![],
358 name: "no-prefix".into(),
359 }],
360 };
361 let auth = ApiKeyAuthenticator::from_config(&config).unwrap();
362 assert_eq!(auth.static_keys.len(), 1);
363 }
364
365 #[test]
366 fn sha256_hash_is_deterministic() {
367 let h1 = sha256_hash(b"hello");
368 let h2 = sha256_hash(b"hello");
369 assert_eq!(h1, h2);
370 let h3 = sha256_hash(b"world");
372 assert_ne!(h1, h3);
373 }
374
375 #[test]
376 fn unsupported_algorithm_returns_none() {
377 let mut config = test_config("key");
378 config.hash_algorithm = "bcrypt".into();
379 assert!(ApiKeyAuthenticator::from_config(&config).is_none());
380 }
381}