Use HTTP Basic auth for mirror push to GitHub
GitHub's git smart HTTP endpoint rejects `Authorization: Bearer <PAT>`
even though the REST API accepts it. The 401 made git fall back to
prompting for a username, which fails in a post-receive hook (no TTY).
Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
diff --git a/Cargo.lock b/Cargo.lock
index a5dd126..e6513d6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1855,6 +1855,7 @@ version = "0.1.0"
dependencies = [
"assert_cmd",
"axum",
+ "base64",
"clap",
"clap_complete",
"fs-err",
diff --git a/Cargo.toml b/Cargo.toml
index 9f18de7..ae0fbd6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ path = "src/bin/quire/main.rs"
[dependencies]
axum = "*"
+base64 = "*"
mlua = { version = "*", features = ["lua54", "serde", "vendored"] }
regex = "*"
serde = { version = "*", features = ["derive"] }
diff --git a/src/quire.rs b/src/quire.rs
index 0a19669..c1c363a 100644
--- a/src/quire.rs
+++ b/src/quire.rs
@@ -159,10 +159,7 @@ impl Repo {
.git(&args)
.env("GIT_CONFIG_COUNT", "1")
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
- .env(
- "GIT_CONFIG_VALUE_0",
- format!("Authorization: Bearer {token}"),
- )
+ .env("GIT_CONFIG_VALUE_0", github_auth_header(token))
.stdout(std::process::Stdio::null())
.status()
.map_err(crate::Error::Io)?;
@@ -219,6 +216,21 @@ impl Repo {
}
}
+/// Build the `Authorization` header value used to authenticate `git push`
+/// to GitHub over HTTPS.
+///
+/// GitHub's git smart HTTP endpoint (`/info/refs`, `git-receive-pack`)
+/// rejects `Authorization: Bearer <PAT>` with 401, even though the REST
+/// API accepts the same token via Bearer. Git then falls back to
+/// prompting for a username, which fails inside a post-receive hook
+/// because there's no TTY. HTTP Basic with any non-empty username and
+/// the token as the password is the documented form for git push.
+fn github_auth_header(token: &str) -> String {
+ use base64::{Engine, engine::general_purpose::STANDARD};
+ let encoded = STANDARD.encode(format!("x-access-token:{token}"));
+ format!("Authorization: Basic {encoded}")
+}
+
/// Application runtime context.
///
/// Carries configuration and provides resolved paths to repositories.
@@ -717,6 +729,24 @@ mod tests {
.to_string()
}
+ #[test]
+ fn github_auth_header_is_basic_with_x_access_token_username() {
+ use base64::{Engine, engine::general_purpose::STANDARD};
+
+ let header = super::github_auth_header("ghp_test");
+ let encoded = header
+ .strip_prefix("Authorization: Basic ")
+ .unwrap_or_else(|| panic!("missing Basic prefix: {header}"));
+ // libcurl rejects header values containing newlines, so the encoded
+ // form must not wrap.
+ assert!(
+ !encoded.contains('\n'),
+ "encoded header value must not wrap: {encoded:?}"
+ );
+ let decoded = STANDARD.decode(encoded).expect("valid base64");
+ assert_eq!(decoded, b"x-access-token:ghp_test");
+ }
+
#[test]
fn push_to_mirror_pushes_main_to_file_mirror() {
let dir = tempfile::tempdir().expect("tempdir");