fraiseql_server/
api_key.rs1use 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)]
81pub(crate) struct 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 pub(crate) 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 = if raw_key.len() > 7 && raw_key.as_bytes()[..7].eq_ignore_ascii_case(b"apikey ") {
184 &raw_key[7..]
185 } else {
186 raw_key
187 };
188
189 let key_hash = sha256_hash(key.as_bytes());
190
191 for static_key in &self.static_keys {
193 if bool::from(key_hash.ct_eq(&static_key.hash)) {
194 debug!(name = %static_key.name, "API key authenticated (static)");
195 let ctx = build_security_context(&static_key.name, &static_key.scopes);
196 return ApiKeyResult::Authenticated(Box::new(ctx));
197 }
198 }
199
200 warn!("API key authentication failed: key not found");
201 ApiKeyResult::Invalid
202 }
203}
204
205impl std::fmt::Debug for ApiKeyAuthenticator {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 f.debug_struct("ApiKeyAuthenticator")
208 .field("header_name", &self.header_name)
209 .field("static_keys_count", &self.static_keys.len())
210 .finish()
211 }
212}
213
214pub(crate) fn sha256_hash(input: &[u8]) -> [u8; 32] {
220 let mut hasher = Sha256::new();
221 hasher.update(input);
222 let result = hasher.finalize();
223 let mut out = [0u8; 32];
224 out.copy_from_slice(&result);
225 out
226}
227
228fn build_security_context(key_name: &str, scopes: &[String]) -> SecurityContext {
230 let user = AuthenticatedUser {
231 user_id: fraiseql_core::types::UserId::new(format!("apikey:{key_name}")),
232 scopes: scopes.to_vec(),
233 expires_at: Utc::now() + chrono::Duration::hours(24),
234 email: None,
235 display_name: None,
236 extra_claims: std::collections::HashMap::new(),
237 };
238 SecurityContext::from_user(&user, format!("apikey-{}", uuid::Uuid::new_v4()))
241 .with_actor_type(fraiseql_core::security::ActorType::ServiceAccount)
242}
243
244pub fn api_key_authenticator_from_schema(
246 schema: &fraiseql_core::schema::CompiledSchema,
247) -> Option<Arc<ApiKeyAuthenticator>> {
248 let security = schema.security.as_ref()?;
249 let api_keys_val = security.additional.get("api_keys")?;
250 let config: ApiKeyConfig = serde_json::from_value(api_keys_val.clone())
251 .map_err(|e| {
252 warn!(error = %e, "Failed to parse security.api_keys config");
253 })
254 .ok()?;
255 ApiKeyAuthenticator::from_config(&config).map(Arc::new)
256}
257
258