Skip to main content

fraiseql_server/
api_key.rs

1//! API key authentication.
2//!
3//! Provides static (env-based) and database-backed API key authentication.
4//! When an `X-API-Key` header (or configured header) is present, the key is
5//! hashed and looked up against configured storage.  A valid key produces a
6//! [`SecurityContext`]; a missing key falls through to JWT authentication.
7//!
8//! # Security
9//!
10//! - Keys are **never** stored or compared in plaintext — only SHA-256 hashes.
11//! - Comparison uses constant-time equality (`subtle::ConstantTimeEq`) to prevent timing
12//!   side-channels.
13//! - Revoked keys (with `revoked_at` set) are rejected.
14
15use 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// ───────────────────────────────────────────────────────────────
26// Configuration (deserialized from compiled schema JSON)
27// ───────────────────────────────────────────────────────────────
28
29/// API key configuration embedded in the compiled schema.
30#[derive(Debug, Clone, Deserialize)]
31pub struct ApiKeyConfig {
32    /// Whether API key authentication is enabled.
33    #[serde(default)]
34    pub enabled: bool,
35
36    /// HTTP header name to read the API key from (default: `x-api-key`).
37    #[serde(default = "default_header")]
38    pub header: String,
39
40    /// Hash algorithm used to store key hashes (`sha256`).
41    #[serde(default = "default_algorithm")]
42    pub hash_algorithm: String,
43
44    /// Storage backend: `"env"` for static keys or `"postgres"` for DB-backed.
45    #[serde(default = "default_storage")]
46    pub storage: String,
47
48    /// Static API keys (only used when `storage = "env"`).
49    #[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/// A single static API key entry from configuration.
64#[derive(Debug, Clone, Deserialize)]
65pub struct StaticApiKeyConfig {
66    /// Hex-encoded SHA-256 hash of the key, optionally prefixed with `sha256:`.
67    pub key_hash: String,
68    /// OAuth-style scopes granted by this key.
69    #[serde(default)]
70    pub scopes:   Vec<String>,
71    /// Human-readable key name (for audit logging).
72    pub name:     String,
73}
74
75// ───────────────────────────────────────────────────────────────
76// Authenticator
77// ───────────────────────────────────────────────────────────────
78
79/// Resolved static key (with parsed hash bytes).
80#[derive(Debug, Clone)]
81struct ResolvedStaticKey {
82    hash:   [u8; 32],
83    scopes: Vec<String>,
84    name:   String,
85}
86
87/// API key authentication result.
88#[derive(Debug)]
89#[non_exhaustive]
90pub enum ApiKeyResult {
91    /// Key found and valid — contains the constructed `SecurityContext`.
92    Authenticated(Box<SecurityContext>),
93    /// No API key header present — caller should fall through to JWT.
94    NotPresent,
95    /// Key was present but invalid or revoked.
96    Invalid,
97}
98
99/// API key authenticator.
100pub struct ApiKeyAuthenticator {
101    header_name: HeaderName,
102    static_keys: Vec<ResolvedStaticKey>,
103}
104
105impl ApiKeyAuthenticator {
106    /// Build an authenticator from the compiled schema config.
107    ///
108    /// Returns `None` if API key auth is not enabled or configuration is
109    /// invalid (logs warnings).
110    #[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    /// Authenticate a request using the API key header.
169    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        // Strip optional "ApiKey " prefix (for Authorization header usage).
179        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        // Check static keys with constant-time comparison.
187        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
209// ───────────────────────────────────────────────────────────────
210// Helpers
211// ───────────────────────────────────────────────────────────────
212
213/// SHA-256 hash of input bytes.
214fn 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
223/// Build a `SecurityContext` for an API key identity.
224fn 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
233/// Build an `ApiKeyAuthenticator` from the compiled schema's `security.api_keys` JSON.
234pub 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// ───────────────────────────────────────────────────────────────
248// Tests
249// ───────────────────────────────────────────────────────────────
250
251#[cfg(test)]
252mod tests {
253    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
254
255    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, // no "sha256:" prefix
357                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        // Different input → different hash.
371        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}