Skip to main content

embacle_server/uhp/
auth.rs

1// ABOUTME: Bearer auth for UHP routes, answering refusals in the UHP error envelope
2// ABOUTME: Separate from the OpenAI-surface middleware because the error shape differs
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7//! Authentication for the UHP surface.
8//!
9//! The same `EMBACLE_API_KEY` as the rest of the server, but a refusal must be
10//! the UHP envelope with `error.type` of `authentication_error` — the suite
11//! reads that field, so reusing the OpenAI-surface middleware would fail A-02
12//! while looking correct.
13//!
14//! Discovery is deliberately **not** behind this: a client has to be able to
15//! learn whether it is talking to a UHP server before deciding what credential
16//! to present.
17
18use std::env;
19
20use axum::extract::Request;
21use axum::http::header::AUTHORIZATION;
22use axum::middleware::Next;
23use axum::response::{IntoResponse, Response};
24use subtle::ConstantTimeEq;
25
26use super::error::UhpFailure;
27
28/// The variable holding the key, shared with the rest of the server.
29const API_KEY_ENV: &str = "EMBACLE_API_KEY";
30
31/// Require a bearer token matching `EMBACLE_API_KEY`, when one is configured.
32///
33/// With no key set the server is in local development mode and every request
34/// passes, matching the behaviour of the `OpenAI` surface beside it.
35pub async fn require_auth(request: Request, next: Next) -> Response {
36    let expected = match env::var(API_KEY_ENV) {
37        Ok(key) if !key.is_empty() => key,
38        _ => return next.run(request).await,
39    };
40
41    let presented = request
42        .headers()
43        .get(AUTHORIZATION)
44        .and_then(|v| v.to_str().ok())
45        .and_then(|v| v.strip_prefix("Bearer "))
46        .unwrap_or_default();
47
48    let matches = expected.len() == presented.len()
49        && bool::from(expected.as_bytes().ct_eq(presented.as_bytes()));
50
51    if matches {
52        next.run(request).await
53    } else if presented.is_empty() {
54        UhpFailure::unauthorized(
55            "missing_credential",
56            "this endpoint requires a bearer token",
57        )
58        .into_response()
59    } else {
60        UhpFailure::unauthorized("invalid_credential", "the bearer token was not accepted")
61            .into_response()
62    }
63}