1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
//! API key authentication.
//!
//! Provides static (env-based) and database-backed API key authentication.
//! When an `X-API-Key` header (or configured header) is present, the key is
//! hashed and looked up against configured storage. A valid key produces a
//! [`SecurityContext`]; a missing key falls through to JWT authentication.
//!
//! # Security
//!
//! - Keys are **never** stored or compared in plaintext — only SHA-256 hashes.
//! - Comparison uses constant-time equality (`subtle::ConstantTimeEq`) to prevent timing
//! side-channels.
//! - Revoked keys (with `revoked_at` set) are rejected.
use std::sync::Arc;
use axum::http::{HeaderMap, HeaderName};
use chrono::Utc;
use fraiseql_core::security::{AuthenticatedUser, SecurityContext};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use tracing::{debug, warn};
// ───────────────────────────────────────────────────────────────
// Configuration (deserialized from compiled schema JSON)
// ───────────────────────────────────────────────────────────────
/// API key configuration embedded in the compiled schema.
#[derive(Debug, Clone, Deserialize)]
pub struct ApiKeyConfig {
/// Whether API key authentication is enabled.
#[serde(default)]
pub enabled: bool,
/// HTTP header name to read the API key from (default: `x-api-key`).
#[serde(default = "default_header")]
pub header: String,
/// Hash algorithm used to store key hashes (`sha256`).
#[serde(default = "default_algorithm")]
pub hash_algorithm: String,
/// Storage backend: `"env"` for static keys or `"postgres"` for DB-backed.
#[serde(default = "default_storage")]
pub storage: String,
/// Static API keys (only used when `storage = "env"`).
#[serde(default, rename = "static")]
pub static_keys: Vec<StaticApiKeyConfig>,
}
fn default_header() -> String {
"x-api-key".into()
}
fn default_algorithm() -> String {
"sha256".into()
}
fn default_storage() -> String {
"env".into()
}
/// A single static API key entry from configuration.
#[derive(Debug, Clone, Deserialize)]
pub struct StaticApiKeyConfig {
/// Hex-encoded SHA-256 hash of the key, optionally prefixed with `sha256:`.
pub key_hash: String,
/// OAuth-style scopes granted by this key.
#[serde(default)]
pub scopes: Vec<String>,
/// Human-readable key name (for audit logging).
pub name: String,
}
// ───────────────────────────────────────────────────────────────
// Authenticator
// ───────────────────────────────────────────────────────────────
/// Resolved static key (with parsed hash bytes).
#[derive(Debug, Clone)]
pub(crate) struct ResolvedStaticKey {
hash: [u8; 32],
scopes: Vec<String>,
name: String,
}
/// API key authentication result.
#[derive(Debug)]
#[non_exhaustive]
pub enum ApiKeyResult {
/// Key found and valid — contains the constructed `SecurityContext`.
Authenticated(Box<SecurityContext>),
/// No API key header present — caller should fall through to JWT.
NotPresent,
/// Key was present but invalid or revoked.
Invalid,
}
/// API key authenticator.
pub struct ApiKeyAuthenticator {
header_name: HeaderName,
pub(crate) static_keys: Vec<ResolvedStaticKey>,
}
impl ApiKeyAuthenticator {
/// Build an authenticator from the compiled schema config.
///
/// Returns `None` if API key auth is not enabled or configuration is
/// invalid (logs warnings).
#[must_use]
pub fn from_config(config: &ApiKeyConfig) -> Option<Self> {
if !config.enabled {
return None;
}
let header_name: HeaderName = config
.header
.parse()
.map_err(|e| {
warn!(header = %config.header, error = %e, "Invalid API key header name");
})
.ok()?;
if config.hash_algorithm != "sha256" {
warn!(
algorithm = %config.hash_algorithm,
"Unsupported API key hash algorithm — only sha256 is supported"
);
return None;
}
let mut static_keys = Vec::new();
for entry in &config.static_keys {
let hex_str = entry.key_hash.strip_prefix("sha256:").unwrap_or(&entry.key_hash);
match hex::decode(hex_str) {
Ok(bytes) if bytes.len() == 32 => {
let mut hash = [0u8; 32];
hash.copy_from_slice(&bytes);
static_keys.push(ResolvedStaticKey {
hash,
scopes: entry.scopes.clone(),
name: entry.name.clone(),
});
},
Ok(bytes) => {
warn!(
name = %entry.name,
len = bytes.len(),
"API key hash has wrong length (expected 32 bytes)"
);
},
Err(e) => {
warn!(
name = %entry.name,
error = %e,
"API key hash is not valid hex"
);
},
}
}
Some(Self {
header_name,
static_keys,
})
}
/// Authenticate a request using the API key header.
pub async fn authenticate(&self, headers: &HeaderMap) -> ApiKeyResult {
let raw_key = match headers.get(&self.header_name) {
Some(v) => match v.to_str() {
Ok(s) if !s.is_empty() => s,
_ => return ApiKeyResult::NotPresent,
},
None => return ApiKeyResult::NotPresent,
};
// Strip optional "ApiKey " prefix (case-insensitive, for Authorization
// header usage). Compare on bytes, not a `str` slice: `raw_key[..7]`
// panics when byte 7 lands inside a multi-byte UTF-8 char in the
// attacker-controlled header value (audit H20 class). A matched prefix
// is ASCII, so the `[7..]` slice that follows is on a char boundary.
let key = if raw_key.len() > 7 && raw_key.as_bytes()[..7].eq_ignore_ascii_case(b"apikey ") {
&raw_key[7..]
} else {
raw_key
};
let key_hash = sha256_hash(key.as_bytes());
// Check static keys with constant-time comparison.
for static_key in &self.static_keys {
if bool::from(key_hash.ct_eq(&static_key.hash)) {
debug!(name = %static_key.name, "API key authenticated (static)");
let ctx = build_security_context(&static_key.name, &static_key.scopes);
return ApiKeyResult::Authenticated(Box::new(ctx));
}
}
warn!("API key authentication failed: key not found");
ApiKeyResult::Invalid
}
}
impl std::fmt::Debug for ApiKeyAuthenticator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ApiKeyAuthenticator")
.field("header_name", &self.header_name)
.field("static_keys_count", &self.static_keys.len())
.finish()
}
}
// ───────────────────────────────────────────────────────────────
// Helpers
// ───────────────────────────────────────────────────────────────
/// SHA-256 hash of input bytes.
pub(crate) fn sha256_hash(input: &[u8]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(input);
let result = hasher.finalize();
let mut out = [0u8; 32];
out.copy_from_slice(&result);
out
}
/// Build a `SecurityContext` for an API key identity.
fn build_security_context(key_name: &str, scopes: &[String]) -> SecurityContext {
let user = AuthenticatedUser {
user_id: fraiseql_core::types::UserId::new(format!("apikey:{key_name}")),
scopes: scopes.to_vec(),
expires_at: Utc::now() + chrono::Duration::hours(24),
email: None,
display_name: None,
extra_claims: std::collections::HashMap::new(),
};
// An API key is a non-human credential: classify it ServiceAccount explicitly
// (compile-checked) rather than relying on a token marker (#390).
SecurityContext::from_user(&user, format!("apikey-{}", uuid::Uuid::new_v4()))
.with_actor_type(fraiseql_core::security::ActorType::ServiceAccount)
}
/// Build an `ApiKeyAuthenticator` from the compiled schema's `security.api_keys` JSON.
pub fn api_key_authenticator_from_schema(
schema: &fraiseql_core::schema::CompiledSchema,
) -> Option<Arc<ApiKeyAuthenticator>> {
let security = schema.security.as_ref()?;
let api_keys_val = security.additional.get("api_keys")?;
let config: ApiKeyConfig = serde_json::from_value(api_keys_val.clone())
.map_err(|e| {
warn!(error = %e, "Failed to parse security.api_keys config");
})
.ok()?;
ApiKeyAuthenticator::from_config(&config).map(Arc::new)
}
// ───────────────────────────────────────────────────────────────