use crate::config::server::ApiConfig;
use axum::{
body::Body,
extract::{Request, State},
http::{HeaderMap, StatusCode, header},
middleware::Next,
response::Response,
};
use secrecy::{ExposeSecret as _, SecretString};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use subtle::ConstantTimeEq as _;
use tracing::{debug, warn};
pub(crate) struct ApiAuth {
accepted: Vec<(
[u8; 32],
String,
AtomicBool, // true means do not emit low length warnings (anymore).
)>,
startup_token: SecretString,
}
impl ApiAuth {
pub(crate) fn new(config: &ApiConfig, api_token: Option<SecretString>) -> Self {
let startup_token = SecretString::from(crate::api::generate_token());
let mut accepted = vec![(
crate::api::token_digest(startup_token.expose_secret()),
"startup-token".to_string(),
AtomicBool::default(),
)];
for digest in &config.token_hashes {
accepted.push((digest.0, hash_prefix_label(digest), AtomicBool::default()));
}
if let Some(token) = api_token {
let digest = crate::api::token_digest(token.expose_secret());
accepted.push((
digest,
hash_prefix_label(&concepts::component_id::Digest(digest)),
AtomicBool::default(), ));
}
Self {
accepted,
startup_token,
}
}
pub(crate) fn startup_token(&self) -> &SecretString {
&self.startup_token
}
fn check(&self, headers: &HeaderMap) -> Result<&str, &'static str> {
let presented = headers
.get(header::AUTHORIZATION)
.ok_or("missing `authorization` header")?
.to_str()
.map_err(|_| "malformed `authorization` header")?;
let (scheme, token) = presented
.split_once(' ')
.ok_or("malformed `authorization` header")?;
if !scheme.eq_ignore_ascii_case("bearer") {
return Err("unsupported `authorization` scheme, expected `Bearer`");
}
let token = token.trim();
let digest = crate::api::token_digest(token);
let (_, identity, warned_short) = self
.accepted
.iter()
.find(|(accepted, _, _)| accepted.ct_eq(&digest).into())
.ok_or("unknown token")?;
if token.len() < crate::api::MIN_API_TOKEN_CHAR_LENGTH
&& !warned_short.swap(true, Ordering::Relaxed)
{
warn!(
identity,
length = token.len(),
minimum = crate::api::MIN_API_TOKEN_CHAR_LENGTH,
"Accepted a short API token for compatibility; replace it with `obelisk generate token`"
);
}
Ok(identity)
}
}
pub(crate) async fn auth_middleware(
State(auth): State<Arc<ApiAuth>>,
req: Request,
next: Next,
) -> Response {
match auth.check(req.headers()) {
Ok(identity) => {
debug!(identity, path = %req.uri().path(), "Authorized API request");
next.run(req).await
}
Err(reason) => {
warn!(
"Denied {} {}: {reason}. Clients must send `Authorization: Bearer <token>` \
(CLI: `--api-token` or OBELISK_API_TOKEN). This server's startup token: {}",
req.method(),
req.uri().path(),
auth.startup_token.expose_secret()
);
deny_response(req.headers())
}
}
}
fn deny_response(headers: &HeaderMap) -> Response {
let grpc_content_type = headers
.get(header::CONTENT_TYPE)
.and_then(|content_type| content_type.to_str().ok())
.filter(|content_type| content_type.starts_with("application/grpc"));
if let Some(content_type) = grpc_content_type {
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type)
.header("grpc-status", "16")
.header("grpc-message", "missing or invalid API token")
.body(Body::empty())
.expect("static response must build")
} else {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header(header::WWW_AUTHENTICATE, "Bearer")
.body(Body::from("missing or invalid API token"))
.expect("static response must build")
}
}
fn hash_prefix_label(digest: &concepts::component_id::Digest) -> String {
format!("token:{}", &digest.to_string()["sha256:".len()..][..8])
}