All checks were successful
CI / Format (push) Successful in 38s
CI / CUDA type-check (push) Successful in 1m41s
CI / Clippy (push) Successful in 2m20s
CI / Test (push) Successful in 4m37s
CI / Build cortex SRPM (push) Has been skipped
CI / Build neuron SRPM (push) Has been skipped
CI / Publish cortex to COPR (push) Has been skipped
CI / Publish neuron to COPR (push) Has been skipped
CI / Bump version in source (push) Has been skipped
build-prerelease / Resolve version stamps + change detection (push) Successful in 32s
build-prerelease / Lint (fmt + clippy) (push) Successful in 2m21s
build-prerelease / Build neuron-blackwell (push) Has been skipped
build-prerelease / Build neuron-ampere (push) Has been skipped
build-prerelease / Build neuron-ada (push) Has been skipped
build-prerelease / Package helexa-neuron-ada RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-ampere RPM (push) Has been skipped
build-prerelease / Package helexa-neuron-blackwell RPM (push) Has been skipped
build-prerelease / Build helexa-bench binary (push) Has been skipped
build-prerelease / Package helexa-bench RPM (push) Has been skipped
build-prerelease / Build cortex binary (push) Successful in 2m35s
build-prerelease / Test (push) Successful in 4m39s
build-prerelease / Package cortex RPM (push) Successful in 1m25s
build-prerelease / Publish to rpm.lair.cafe (unstable) (push) Successful in 52s
Regression from #49: the auth middleware rejected ANY present-but- unresolvable bearer token with 401 invalid_api_key, even when require_auth=false. But OpenAI-compatible clients (opencode, Open WebUI, Agent Zero, litellm) send a placeholder bearer by default — so enabling the build broke every existing client even though the operator never opted into auth. Pre-#49 the bearer was never inspected at all. Fix: in allow-anonymous mode (require_auth=false, the default) an unrecognized key is now ignored and the request is served anonymously, restoring pre-#49 behaviour. A bad key only 401s when require_auth=true. A valid key is still resolved + metered in both modes. Test renamed/split: unrecognized_key_is_ignored_when_auth_not_required (now 200, served anonymously) + invalid_key_is_401_when_auth_required. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
5.4 KiB
Rust
134 lines
5.4 KiB
Rust
//! API-key authentication + principal resolution (#49).
|
|
//!
|
|
//! Identity rides standard bearer auth only — `Authorization: Bearer <key>`
|
|
//! — which is what keeps every tier OpenAI-compatible by construction (no
|
|
//! custom required headers or body fields, per #47). The middleware resolves
|
|
//! the key to a [`Principal`] via the [`EntitlementProvider`], carries it in
|
|
//! the request extensions for cortex-side metering/enforcement (#51/#52), and
|
|
//! stamps it as internal headers on the request so it reaches neuron, which
|
|
//! trusts cortex's assertion over WireGuard (#54).
|
|
//!
|
|
//! Anti-spoofing: any client-supplied principal header is **stripped** before
|
|
//! the authoritative value is stamped, so a client can never assert a
|
|
//! principal it didn't authenticate as.
|
|
//!
|
|
//! Rejection contract (#63): missing key under `require_auth`, or any present
|
|
//! but unresolvable key, yields `401 invalid_api_key` in the #60 envelope.
|
|
|
|
use crate::error::envelope_response;
|
|
use crate::state::CortexState;
|
|
use axum::extract::{Request, State};
|
|
use axum::http::header::AUTHORIZATION;
|
|
use axum::http::{HeaderMap, HeaderValue};
|
|
use axum::middleware::Next;
|
|
use axum::response::Response;
|
|
use cortex_core::entitlements::{HEADER_ACCOUNT_ID, HEADER_KEY_ID};
|
|
use cortex_core::error_envelope::OpenAiError;
|
|
use std::sync::Arc;
|
|
|
|
/// Endpoints that never require auth: liveness/readiness probes. Everything
|
|
/// else flows through resolution.
|
|
fn is_public(path: &str) -> bool {
|
|
path == "/health" || path == "/"
|
|
}
|
|
|
|
/// Extract the bearer token from an `Authorization` header value, if present
|
|
/// and well-formed. Scheme match is case-insensitive per RFC 7235.
|
|
fn parse_bearer(headers: &HeaderMap) -> Option<String> {
|
|
let raw = headers.get(AUTHORIZATION)?.to_str().ok()?;
|
|
let (scheme, token) = raw.split_once(' ')?;
|
|
if scheme.eq_ignore_ascii_case("bearer") {
|
|
let token = token.trim();
|
|
(!token.is_empty()).then(|| token.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Axum middleware: resolve the bearer key, attach the principal, stamp the
|
|
/// internal headers. Wired in `build_app` via `from_fn_with_state`.
|
|
pub async fn require_principal(
|
|
State(fleet): State<Arc<CortexState>>,
|
|
mut req: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
if is_public(req.uri().path()) {
|
|
return next.run(req).await;
|
|
}
|
|
|
|
// Anti-spoof: drop any client-supplied principal headers up front.
|
|
{
|
|
let headers = req.headers_mut();
|
|
headers.remove(HEADER_ACCOUNT_ID);
|
|
headers.remove(HEADER_KEY_ID);
|
|
}
|
|
|
|
match parse_bearer(req.headers()) {
|
|
Some(key) => match fleet.entitlements.resolve(&key).await {
|
|
Ok(principal) => {
|
|
// Stamp the authoritative principal for neuron. Account/key
|
|
// ids come from operator config, so they're valid header
|
|
// values; guard anyway and skip a malformed one rather than
|
|
// panic.
|
|
if let (Ok(account), Ok(key_id)) = (
|
|
HeaderValue::from_str(&principal.account_id),
|
|
HeaderValue::from_str(&principal.key_id),
|
|
) {
|
|
let headers = req.headers_mut();
|
|
headers.insert(HEADER_ACCOUNT_ID, account);
|
|
headers.insert(HEADER_KEY_ID, key_id);
|
|
}
|
|
// Carry the typed principal for cortex-side metering (#51)
|
|
// and budget enforcement (#52).
|
|
req.extensions_mut().insert(principal);
|
|
next.run(req).await
|
|
}
|
|
// An unrecognized key only hard-fails when auth is *required*.
|
|
// In allow-anonymous mode (the default) we must IGNORE it and
|
|
// serve the request unauthenticated — otherwise the placeholder
|
|
// keys that OpenAI-compatible clients send by default (opencode,
|
|
// Open WebUI, Agent Zero, litellm) would all break, even though
|
|
// the operator never opted into auth. Pre-#49 the bearer was
|
|
// never inspected at all; this preserves that for require_auth=false.
|
|
Err(_) => {
|
|
if fleet.require_auth {
|
|
unauthorized("invalid API key")
|
|
} else {
|
|
tracing::debug!(
|
|
"ignoring unrecognized bearer token (require_auth=false): serving anonymously"
|
|
);
|
|
next.run(req).await
|
|
}
|
|
}
|
|
},
|
|
None => {
|
|
if fleet.require_auth {
|
|
unauthorized("missing API key; supply 'Authorization: Bearer <key>'")
|
|
} else {
|
|
next.run(req).await
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `401 invalid_api_key` in the standard envelope (#63).
|
|
fn unauthorized(message: &str) -> Response {
|
|
envelope_response(OpenAiError::invalid_api_key(message))
|
|
}
|
|
|
|
/// Copy the cortex-stamped principal headers from an inbound [`HeaderMap`]
|
|
/// onto an outbound reqwest builder. Used by the Anthropic proxy paths,
|
|
/// which construct their own upstream requests instead of going through
|
|
/// [`crate::proxy::forward_request`] (which forwards all headers verbatim).
|
|
pub fn forward_principal_headers(
|
|
mut builder: reqwest::RequestBuilder,
|
|
headers: &HeaderMap,
|
|
) -> reqwest::RequestBuilder {
|
|
for name in [HEADER_ACCOUNT_ID, HEADER_KEY_ID] {
|
|
if let Some(value) = headers.get(name) {
|
|
builder = builder.header(name, value);
|
|
}
|
|
}
|
|
builder
|
|
}
|