Use axum-extra TypedHeader, pre-resolve HMAC key, and add TraceLayer
- Implement headers::Credentials for HmacSha256Sig and use TypedHeader
  in the HmacSha256Auth extractor, consistent with quire-server's bearer
  token pattern
- Resolve webhook_secret bytes once in QuireCi::new() so the handler
  accesses hmac_key() directly with no per-request reveal() call;
  removes SecretUnavailable from WebhookError
- Add tower-http TraceLayer with a span per request (method +
  matched_path) for structured HTTP observability
- Add #[serde(rename = "ref")] to PushRef.ref_name so the wire format
  uses "ref" while the internal Rust name stays ref_name

https://claude.ai/code/session_01YXVu67hVnQSAY8wzmfmRHw
change
commit 7d3c40e5347ff12d3d7d8529dfac298d9aa41d48
author Claude <noreply@anthropic.com>
date
parent 87c75270
diff --git a/Cargo.lock b/Cargo.lock
index 2d0d6ae..340a2a2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2627,6 +2627,7 @@ name = "quire-ci"
 version = "0.1.0"
 dependencies = [
  "axum",
+ "axum-extra",
  "facet",
  "figue",
  "fs-err",
@@ -2651,6 +2652,7 @@ dependencies = [
  "thiserror",
  "tokio",
  "tower",
+ "tower-http",
  "tracing",
  "tracing-opentelemetry",
  "tracing-subscriber",
@@ -3728,6 +3730,7 @@ dependencies = [
  "tower",
  "tower-layer",
  "tower-service",
+ "tracing",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index d5ea1c2..6b57c3a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,8 @@ resolver = "3"
 
 [workspace.dependencies]
 axum = "*"
+axum-extra = { version = "*", features = ["typed-header"] }
+tower-http = { version = "*", features = ["trace"] }
 clap = { version = "*", features = ["derive", "env"] }
 facet = "*"
 fs-err = "*"
diff --git a/quire-ci/Cargo.toml b/quire-ci/Cargo.toml
index 689fd96..6e2abb7 100644
--- a/quire-ci/Cargo.toml
+++ b/quire-ci/Cargo.toml
@@ -5,6 +5,8 @@ edition = "2024"
 
 [dependencies]
 axum = { workspace = true }
+axum-extra = { workspace = true }
+tower-http = { workspace = true }
 facet = { workspace = true }
 figue = "*"
 fs-err = { workspace = true }
diff --git a/quire-ci/src/quire.rs b/quire-ci/src/quire.rs
index 360c0a1..2521b4d 100644
--- a/quire-ci/src/quire.rs
+++ b/quire-ci/src/quire.rs
@@ -30,6 +30,7 @@ pub use quire_core::telemetry::SentryConfig;
 pub struct QuireCi {
     config: GlobalConfig,
     db: Db,
+    hmac_key: Vec<u8>,
 }
 
 impl QuireCi {
@@ -40,8 +41,18 @@ impl QuireCi {
         }
         let fennel = Fennel::new().into_diagnostic()?;
         let config: GlobalConfig = fennel.load_file(&config_path).into_diagnostic()?;
+        let hmac_key = config
+            .webhook_secret
+            .reveal()
+            .into_diagnostic()?
+            .as_bytes()
+            .to_vec();
         let db = Db::open(&base_dir.join("quire-ci.db")).into_diagnostic()?;
-        Ok(Self { config, db })
+        Ok(Self {
+            config,
+            db,
+            hmac_key,
+        })
     }
 
     pub fn config(&self) -> &GlobalConfig {
@@ -51,6 +62,10 @@ impl QuireCi {
     pub fn db(&self) -> &Db {
         &self.db
     }
+
+    pub fn hmac_key(&self) -> &[u8] {
+        &self.hmac_key
+    }
 }
 
 #[cfg(test)]
diff --git a/quire-ci/src/server.rs b/quire-ci/src/server.rs
index cd5364b..7434738 100644
--- a/quire-ci/src/server.rs
+++ b/quire-ci/src/server.rs
@@ -1,14 +1,19 @@
 use std::net::SocketAddr;
 
 use axum::Router;
-use axum::extract::State;
-use axum::http::{HeaderMap, StatusCode};
+use axum::extract::{MatchedPath, State};
+use axum::http::{HeaderMap, Request, StatusCode};
 use axum::response::IntoResponse;
 use axum::routing::{get, post};
+use axum_extra::TypedHeader;
+use axum_extra::headers::Authorization;
+use axum_extra::headers::authorization::Credentials;
 use hmac::{Hmac, KeyInit, Mac};
 use quire_core::event::PushEvent;
 use quire_core::telemetry::{self, FmtMode};
 use sha2::Sha256;
+use tower_http::trace::TraceLayer;
+use tracing::info_span;
 
 use crate::quire::QuireCi;
 
@@ -31,17 +36,9 @@ enum WebhookError {
     #[error(transparent)]
     InvalidPayload(#[from] serde_json::Error),
     #[error(transparent)]
-    SecretUnavailable(#[from] quire_core::secret::Error),
-    #[error(transparent)]
     Db(#[from] rusqlite::Error),
 }
 
-impl From<hex::FromHexError> for WebhookError {
-    fn from(_: hex::FromHexError) -> Self {
-        Self::InvalidSignature
-    }
-}
-
 impl From<hmac::digest::MacError> for WebhookError {
     fn from(_: hmac::digest::MacError) -> Self {
         Self::InvalidSignature
@@ -55,14 +52,28 @@ impl IntoResponse for WebhookError {
                 StatusCode::UNAUTHORIZED
             }
             WebhookError::InvalidPayload(_) => StatusCode::BAD_REQUEST,
-            WebhookError::SecretUnavailable(_) | WebhookError::Db(_) => {
-                StatusCode::INTERNAL_SERVER_ERROR
-            }
+            WebhookError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
         }
         .into_response()
     }
 }
 
+struct HmacSha256Sig(Vec<u8>);
+
+impl Credentials for HmacSha256Sig {
+    const SCHEME: &'static str = "HMAC-SHA256";
+
+    fn decode(value: &axum::http::HeaderValue) -> Option<Self> {
+        let hex_str = value.to_str().ok()?.strip_prefix("HMAC-SHA256 ")?;
+        hex::decode(hex_str).ok().map(Self)
+    }
+
+    fn encode(&self) -> axum::http::HeaderValue {
+        axum::http::HeaderValue::from_str(&format!("HMAC-SHA256 {}", hex::encode(&self.0)))
+            .expect("hex is always a valid header value")
+    }
+}
+
 struct HmacSha256Auth(Vec<u8>);
 
 impl<S: Send + Sync> axum::extract::FromRequestParts<S> for HmacSha256Auth {
@@ -70,15 +81,20 @@ impl<S: Send + Sync> axum::extract::FromRequestParts<S> for HmacSha256Auth {
 
     async fn from_request_parts(
         parts: &mut axum::http::request::Parts,
-        _state: &S,
+        state: &S,
     ) -> std::result::Result<Self, WebhookError> {
-        let hex_str = parts
-            .headers
-            .get(axum::http::header::AUTHORIZATION)
-            .and_then(|v| v.to_str().ok())
-            .and_then(|s| s.strip_prefix("HMAC-SHA256 "))
-            .ok_or(WebhookError::MissingSignature)?;
-        Ok(Self(hex::decode(hex_str)?))
+        use axum::extract::FromRequestParts;
+
+        let Some(TypedHeader(Authorization(sig))) =
+            <TypedHeader<Authorization<HmacSha256Sig>> as FromRequestParts<S>>::from_request_parts(
+                parts, state,
+            )
+            .await
+            .ok()
+        else {
+            return Err(WebhookError::MissingSignature);
+        };
+        Ok(Self(sig.0))
     }
 }
 
@@ -88,16 +104,8 @@ async fn webhook(
     headers: HeaderMap,
     body: axum::body::Bytes,
 ) -> std::result::Result<StatusCode, WebhookError> {
-    let secret_bytes = quire
-        .config()
-        .webhook_secret
-        .reveal()
-        .inspect_err(|e| tracing::error!(error = %e, "failed to resolve webhook secret"))?
-        .as_bytes()
-        .to_vec();
-
     let mut mac =
-        Hmac::<Sha256>::new_from_slice(&secret_bytes).expect("HMAC accepts any key length");
+        Hmac::<Sha256>::new_from_slice(quire.hmac_key()).expect("HMAC accepts any key length");
     mac.update(&body);
     mac.verify_slice(&provided_bytes)?;
 
@@ -161,6 +169,15 @@ pub async fn run(quire: QuireCi) -> Result<()> {
         .route("/health", get(health))
         .route("/", get(index))
         .route("/webhook", post(webhook))
+        .layer(
+            TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
+                let matched_path = request
+                    .extensions()
+                    .get::<MatchedPath>()
+                    .map(MatchedPath::as_str);
+                info_span!("http_request", method = ?request.method(), matched_path)
+            }),
+        )
         .with_state(quire);
 
     let addr = SocketAddr::from(([0, 0, 0, 0], port));
@@ -205,7 +222,7 @@ mod tests {
             "pushed_at": "2026-05-01T00:00:00Z",
             "refs": [
                 {
-                    "ref_name": "refs/heads/main",
+                    "ref": "refs/heads/main",
                     "old_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                     "new_sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
                 }
diff --git a/quire-core/src/event.rs b/quire-core/src/event.rs
index 55adfcd..66d4752 100644
--- a/quire-core/src/event.rs
+++ b/quire-core/src/event.rs
@@ -1,6 +1,7 @@
 /// A single ref update from a push.
 #[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
 pub struct PushRef {
+    #[serde(rename = "ref")]
     pub ref_name: String,
     pub old_sha: String,
     pub new_sha: String,