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;
use subtle::ConstantTimeEq as _;
use tracing::{debug, warn};
pub(crate) struct ApiAuth {
accepted: Vec<([u8; 32], String)>,
startup_token: SecretString,
}
impl ApiAuth {
pub(crate) fn new(config: &ApiConfig) -> 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(),
)];
for digest in &config.token_hashes {
accepted.push((digest.0, hash_prefix_label(digest)));
}
if let Some(token) = &config.token {
let digest = crate::api::token_digest(token.expose_secret());
accepted.push((
digest,
hash_prefix_label(&concepts::component_id::Digest(digest)),
));
}
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 digest = crate::api::token_digest(token.trim());
self.accepted
.iter()
.find(|(accepted, _)| accepted.ct_eq(&digest).into())
.map(|(_, identity)| identity.as_str())
.ok_or("unknown token")
}
}
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])
}