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