fraiseql-server 2.15.0

HTTP server for FraiseQL v2 GraphQL engine
//! API key authentication.
//!
//! Provides static (env-based) and Postgres-backed API key authentication
//! (#627).
//! 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.

pub mod postgres;

use axum::http::{HeaderMap, HeaderName};
use chrono::Utc;
// ───────────────────────────────────────────────────────────────
// Configuration (deserialized from compiled schema JSON)
// ───────────────────────────────────────────────────────────────
/// API key configuration embedded in the compiled schema.
///
/// The compiled `[security.api_keys]` shape — the schema seam owns it (#977),
/// so the CLI, the compiled artefact and this server share one type.
pub use fraiseql_core::schema::ApiKeySecurityConfig as ApiKeyConfig;
/// A single static API key entry (`[[security.api_keys.static]]`).
pub use fraiseql_core::schema::StaticApiKeyEntry as StaticApiKeyConfig;
use fraiseql_core::security::{AuthenticatedUser, SecurityContext};
use postgres::PgApiKeyStore;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use tracing::{debug, warn};

// ───────────────────────────────────────────────────────────────
// 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>,
    /// Postgres-backed store, present when `storage = "postgres"` (#627).
    postgres:               Option<PgApiKeyStore>,
}

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,
            postgres: None,
        })
    }

    /// Attach the Postgres-backed store (`storage = "postgres"`, #627).
    #[must_use]
    pub fn with_postgres(mut self, store: PgApiKeyStore) -> Self {
        self.postgres = Some(store);
        self
    }

    /// Whether this authenticator resolves keys against Postgres.
    #[must_use]
    pub const fn has_postgres(&self) -> bool {
        self.postgres.is_some()
    }

    /// The Postgres store, when configured — the admin key-management API
    /// operates on the same store the authenticator reads.
    #[must_use]
    pub const fn postgres_store(&self) -> Option<&PgApiKeyStore> {
        self.postgres.as_ref()
    }

    /// 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));
            }
        }

        // Postgres-stored keys (#627): parse selector+verifier, look the row
        // up by selector (no secret in the WHERE), gate on revocation/expiry,
        // and compare the verifier hash in constant time.
        if let (Some(store), Some((selector, verifier))) =
            (&self.postgres, postgres::parse_key(key))
        {
            match store.resolve(selector).await {
                Ok(Some(row)) => {
                    let now = Utc::now();
                    let revoked = row.revoked_at.is_some();
                    let expired = row.expires_at.is_some_and(|t| t <= now);
                    let verifier_hash = sha256_hash(verifier.as_bytes());
                    let hash_ok = row.verifier_hash.len() == 32
                        && bool::from(verifier_hash.ct_eq(&row.verifier_hash[..]));
                    if hash_ok && !revoked && !expired {
                        debug!(name = %row.name, "API key authenticated (postgres)");
                        if let Err(e) = store.touch(selector).await {
                            // Best-effort stamp; the request is already authenticated.
                            debug!(error = %e, "Failed to stamp api-key last_used_at");
                        }
                        let ctx = build_security_context(&row.name, &row.scopes);
                        return ApiKeyResult::Authenticated(Box::new(ctx));
                    }
                    warn!(revoked, expired, "API key authentication failed: postgres key rejected");
                    return ApiKeyResult::Invalid;
                },
                Ok(None) => {
                    // Fall through to the shared failure path: an unknown
                    // selector must be indistinguishable from a bad verifier.
                },
                Err(e) => {
                    // Fail closed: a dead database must not admit keys, and
                    // must not be mistaken for "key not found".
                    warn!(error = %e, "API key authentication failed: store error");
                    return ApiKeyResult::Invalid;
                },
            }
        }

        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())
            .field("postgres", &self.postgres.is_some())
            .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)
}

/// Parse the compiled schema's `security.api_keys` JSON into an [`ApiKeyConfig`].
///
/// Construction of the authenticator happens later, where the database pool is
/// in scope (`storage = "postgres"` needs it, #627).
#[must_use]
pub fn api_key_config_from_schema(
    schema: &fraiseql_core::schema::CompiledSchema,
) -> Option<ApiKeyConfig> {
    // A typed field (#977): a malformed or misspelled section is a load error at
    // the schema seam, never a warn-and-disable here.
    schema.security.as_ref()?.api_keys.clone()
}

// ───────────────────────────────────────────────────────────────