fix(forge): a gitea 401 retries once as basic auth
A self-hosted Gitea answers 401 to `Authorization: token <password>` and 200 to the same value as `user:password`, and the account store's `secret_kind` cannot tell the two apart — it is a label the human typed at `identity_save`, so a login password saved as a token is indistinguishable from a real PAT until the forge answers. `GiteaForge` had one auth path: the token header, on every call. A watch whose credential resolved to a password therefore 401'd on every poll, forever, while the same secret worked for `git push` over https (the credential helper sends it as a password). The watch looked quiet instead of broken. `GiteaForge::with_login` names the login that owns the secret, and `send` tries the token header first, then — on a 401 only — the same value as Basic auth. The retry is cloned before the token header lands, so the fallback request carries one `Authorization` header and not two. No login (a pasted watch token names none) or an unclonable body leaves the 401 to surface exactly as it did before. The login reaches the client in the next commit; this one is the adapter half, tested against a mock that refuses the token header and accepts the Basic one.
This commit is contained in:
1 parent
6ae21568d0
commit
d31dfeae27
2 files changed
+154
-31
No files matched your search
@@ -1,7 +1,11 @@
|
||||
//! The Gitea adapter: self-hosted, so the base URL is required and points
|
||||
//! at the instance root (`https://forge.example`); every request path is
|
||||
//! prefixed with `/api/v1`. Auth is the token in
|
||||
//! `Authorization: token <token>`.
|
||||
//! `Authorization: token <token>`, with one retry as Basic auth
|
||||
//! (`user:password`) when the instance refuses it: a Gitea instance
|
||||
//! answers 401 to a password sent in the token header while accepting
|
||||
//! the same value as Basic, and an account's `secret_kind` is a label
|
||||
//! its human typed, not a fact.
|
||||
//!
|
||||
//! The endpoints mirror GitHub's shape, with the constraints that differ:
|
||||
//! - Issues come from `GET /repos/{owner}/{repo}/issues` with
|
||||
@@ -59,6 +63,10 @@ const MAX_PAGES: usize = 1000;
|
||||
pub struct GiteaForge {
|
||||
base_url: String,
|
||||
token: String,
|
||||
/// The login that owns `token`, when the caller knows it: a Gitea
|
||||
/// instance takes `user:password` as Basic auth, so a secret the
|
||||
/// token header cannot carry still polls — see [`Self::send`].
|
||||
login: Option<String>,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
@@ -80,6 +88,7 @@ impl GiteaForge {
|
||||
Self {
|
||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||
token: token.into(),
|
||||
login: None,
|
||||
http,
|
||||
}
|
||||
}
|
||||
@@ -91,19 +100,70 @@ impl GiteaForge {
|
||||
self
|
||||
}
|
||||
|
||||
/// A GET builder for `path` with auth applied: the token rides only in
|
||||
/// the `Authorization` header, never in a URL.
|
||||
fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
let base = &self.base_url;
|
||||
let request = self.http.get(format!("{base}/api/v1{path}"));
|
||||
self.auth(request)
|
||||
/// The login that owns the token, when a caller knows it — an account
|
||||
/// identity does, a pasted watch token does not. It is what lets a
|
||||
/// credential the instance refuses as a token be retried as Basic
|
||||
/// auth; blank is the same as never set.
|
||||
pub fn with_login(mut self, login: impl Into<String>) -> Self {
|
||||
let login = login.into();
|
||||
let login = login.trim();
|
||||
self.login = (!login.is_empty()).then(|| login.to_owned());
|
||||
self
|
||||
}
|
||||
|
||||
/// A POST builder for `path` with auth applied.
|
||||
/// A GET builder for `path`, unauthenticated: every call goes
|
||||
/// through [`Self::send`], which applies the credential.
|
||||
fn get(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
let base = &self.base_url;
|
||||
self.http.get(format!("{base}/api/v1{path}"))
|
||||
}
|
||||
|
||||
/// A POST builder for `path`, unauthenticated — see [`Self::get`].
|
||||
fn post(&self, path: &str) -> reqwest::RequestBuilder {
|
||||
let base = &self.base_url;
|
||||
let request = self.http.post(format!("{base}/api/v1{path}"));
|
||||
self.auth(request)
|
||||
self.http.post(format!("{base}/api/v1{path}"))
|
||||
}
|
||||
|
||||
/// Sends `request` with the credential applied, retrying once as
|
||||
/// Basic auth when the instance refuses the token header.
|
||||
///
|
||||
/// `Authorization: token <t>` is the right header for an API token
|
||||
/// and the wrong one for a password: a self-hosted Gitea answers 401
|
||||
/// to a password sent that way, and 200 to the same value as
|
||||
/// `user:password`. Which one a stored secret is cannot be read off
|
||||
/// its `secret_kind` (a label its human typed), so the attempt
|
||||
/// decides: token first, Basic on a 401, and the instance's own
|
||||
/// error body is what surfaces when both fail.
|
||||
///
|
||||
/// The retry is cloned *before* the token header lands, so the
|
||||
/// fallback request carries one `Authorization` header, not two.
|
||||
/// Nothing to retry with (no login, or a body the clone cannot
|
||||
/// carry) leaves the 401 to surface as it always did.
|
||||
async fn send(
|
||||
&self,
|
||||
request: reqwest::RequestBuilder,
|
||||
) -> Result<reqwest::Response, ForgeError> {
|
||||
let retry = if self.login.is_some() {
|
||||
request.try_clone()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let response = self
|
||||
.auth(request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
if response.status() != reqwest::StatusCode::UNAUTHORIZED {
|
||||
return Ok(response);
|
||||
}
|
||||
match (self.login.as_deref(), retry) {
|
||||
(Some(login), Some(retry)) => retry
|
||||
.basic_auth(login, Some(self.token.as_str()))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network),
|
||||
_ => Ok(response),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gitea's token scheme: `Authorization: token <token>`.
|
||||
@@ -125,12 +185,7 @@ impl GiteaForge {
|
||||
let mut query = query.to_vec();
|
||||
query.push(("limit".to_owned(), PER_PAGE.to_string()));
|
||||
query.push(("page".to_owned(), page.to_string()));
|
||||
let response = self
|
||||
.get(path)
|
||||
.query(&query)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let response = self.send(self.get(path).query(&query)).await?;
|
||||
let batch: Vec<T> = decode(response).await?;
|
||||
let full = batch.len() == PER_PAGE;
|
||||
rows.extend(batch);
|
||||
@@ -629,11 +684,8 @@ impl Forge for GiteaForge {
|
||||
repo.owner, repo.name
|
||||
);
|
||||
let response = self
|
||||
.post(&path)
|
||||
.json(&serde_json::json!({ "body": body }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
.send(self.post(&path).json(&serde_json::json!({ "body": body })))
|
||||
.await?;
|
||||
ensure_success(response).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -647,21 +699,13 @@ impl Forge for GiteaForge {
|
||||
// One number space across issues and pulls: `_kind` cannot
|
||||
// change the route (see `comment`).
|
||||
let issue_path = format!("/repos/{}/{}/issues/{number}", repo.owner, repo.name);
|
||||
let response = self
|
||||
.get(&issue_path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let response = self.send(self.get(&issue_path)).await?;
|
||||
let issue: WireIssue = decode(response).await?;
|
||||
if issue.pull_request.is_some() {
|
||||
// The issue view's PR meta is a marker; the pulls row carries
|
||||
// the authoritative merged flag.
|
||||
let pull_path = format!("/repos/{}/{}/pulls/{number}", repo.owner, repo.name);
|
||||
let response = self
|
||||
.get(&pull_path)
|
||||
.send()
|
||||
.await
|
||||
.map_err(ForgeError::network)?;
|
||||
let response = self.send(self.get(&pull_path)).await?;
|
||||
let pull: WirePull = decode(response).await?;
|
||||
Ok(snapshot_from_pull(&pull)?)
|
||||
} else {
|
||||
|
||||
@@ -428,6 +428,85 @@ async fn a_non_2xx_response_maps_to_an_api_error_with_the_body_message() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A secret the instance refuses as a token but accepts as a password —
|
||||
/// the shape a self-hosted Gitea answers to an account whose stored
|
||||
/// `secret_kind` says token and whose value is a login password. The
|
||||
/// retry is what keeps such a watch polling instead of 401-ing forever.
|
||||
#[tokio::test]
|
||||
async fn a_refused_token_header_retries_once_as_basic_auth() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues/7"))
|
||||
.and(header("authorization", "token hunter2"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"message": "invalid username, password or token"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues/7"))
|
||||
// base64("kosmos:hunter2"): the instance's own login form.
|
||||
.and(header("authorization", "Basic a29zbW9zOmh1bnRlcjI="))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(issue_row(7, json!({}))))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let snapshot = GiteaForge::new(server.uri(), "hunter2")
|
||||
.with_login("kosmos")
|
||||
.snapshot(&RepoRef::new("acme", "widgets"), ForgeThreadKind::Issue, 7)
|
||||
.await
|
||||
.expect("the Basic retry authenticates");
|
||||
assert_eq!(snapshot.title, "thread 7");
|
||||
|
||||
let received = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording is on");
|
||||
assert_eq!(received.len(), 2, "one token attempt, then one Basic");
|
||||
assert_eq!(
|
||||
received[0]
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("token hunter2")
|
||||
);
|
||||
assert_eq!(
|
||||
received[1]
|
||||
.headers
|
||||
.get("authorization")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("Basic a29zbW9zOmh1bnRlcjI=")
|
||||
);
|
||||
}
|
||||
|
||||
/// Without a login there is nothing to retry with: the 401 surfaces on
|
||||
/// the first attempt, exactly as before the fallback existed.
|
||||
#[tokio::test]
|
||||
async fn a_refused_token_without_a_login_does_not_retry() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/api/v1/repos/acme/widgets/issues/7"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(json!({
|
||||
"message": "invalid username, password or token"
|
||||
})))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let err = forge(&server)
|
||||
.snapshot(&RepoRef::new("acme", "widgets"), ForgeThreadKind::Issue, 7)
|
||||
.await
|
||||
.expect_err("a refused token is an error");
|
||||
assert!(
|
||||
matches!(err, ForgeError::Api { status: 401, .. }),
|
||||
"got: {err:?}"
|
||||
);
|
||||
let received = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("request recording is on");
|
||||
assert_eq!(received.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_transport_failure_maps_to_network() {
|
||||
// An unroutable address: nothing is listening, so the connect fails.
|
||||
|
||||
Reference in new issue
Block a user