Skip to content

auth: defer credential helper until 401, match git's behaviour#65

Open
Soph wants to merge 6 commits into
mainfrom
soph/issue-63-deferred-credential-helper
Open

auth: defer credential helper until 401, match git's behaviour#65
Soph wants to merge 6 commits into
mainfrom
soph/issue-63-deferred-credential-helper

Conversation

@Soph
Copy link
Copy Markdown
Contributor

@Soph Soph commented May 22, 2026

Summary

Fixes #63. Adopts git's own HTTP auth flow: try anonymous first, only consult the credential helper if the server returns 401.

  • Before: auth.Resolve proactively ran git credential fill for every HTTPS endpoint with no explicit auth. On hosts the user had never authenticated against, git fell back to an interactive Username: / Password: prompt — turning git-sync into an interactive command. And for hosts where the helper did have credentials, we'd leak a token to public repos that didn't need one.
  • After: anonymous request first. On 401, the new CredentialHelper.Lookup is consulted; on success we retry once, store the auth on the conn for follow-up PostRPC calls, and tell the helper to approve the credentials. The GitCredentialHelper implementation runs git with GIT_TERMINAL_PROMPT=0, so a misconfigured helper fails fast rather than blocking on a tty prompt.

What about expired tokens?

If the helper's stored credentials have expired, the retry returns 401 (or 403 for token services like Cloudflare that surface "Invalid or expired token" as 403 instead of 401). In both cases we call Helper.Reject so the helper forgets the stale credentials and the next run starts clean.

The 403 path is deliberately scoped to the retry result only — never the initial response. A 403 on a first anonymous request usually means "this repo is forbidden to you" rather than "your credentials are bad", and we don't want to risk dropping good credentials for an archived/restricted repo.

Test plan

  • Full go test ./... passes
  • go test -race on internal/{gitproto,auth,syncer}
  • golangci-lint run clean
  • New unit tests cover the full lifecycle: anonymous success skips the helper, 401 triggers Lookup + retry + Approve, retry-still-401 and retry-403 both trigger Reject, helper-with-no-credentials surfaces the original 401 cleanly, and explicit auth disables the helper fallback entirely
  • New auth tests cover GitCredentialHelper.{Lookup,Approve,Reject} and the GIT_TERMINAL_PROMPT=0 env contract
  • Pre-existing end-to-end TestRun_IntegrationUsesGitCredentialHelperFallback still passes — exercises the 401-retry path against a real httptest basic-auth server
  • Manual: reproduce the public-repo case from the issue (sync from gitlab.baylibre.com without credentials configured) and confirm no prompt

🤖 Generated with Claude Code


Note

Medium Risk
Changes the smart-HTTP authentication flow to retry /info/refs with helper-supplied Basic auth on 401 and persist that auth for subsequent requests; mistakes here could cause auth failures or unintended retries. Risk is moderated by extensive new unit/integration tests and keeping explicit auth precedence unchanged.

Overview
Switches HTTP auth resolution to stop eagerly invoking the git credential helper during auth.Resolve, preventing credential leakage to public repos and avoiding interactive prompts.

Adds a CredentialHelper hook to smart-HTTP connections so that when an anonymous /info/refs request returns 401, git-sync performs a single credential-helper lookup, retries with Basic auth, then persists the resolved auth for the rest of the connection and signals approve/reject (including rejecting retry-403 as expired/invalid token). Also forces git credential calls to be non-interactive via GIT_TERMINAL_PROMPT=0.

Updates syncer wiring and tests accordingly, adding coverage for helper lookup/approve/reject behavior, non-HTTP endpoints, and ensuring explicit auth disables helper fallback.

Reviewed by Cursor Bugbot for commit 15b80ac. Configure here.

git-sync used to call `git credential fill` proactively whenever an HTTP
endpoint had no explicit auth. Two problems with that:

- On hosts the user had never authenticated against, git fell back to
  an interactive `Username:`/`Password:` prompt — turning git-sync into
  an interactive command and breaking non-interactive runs (issue #63).
- For hosts where the helper *did* have credentials, we'd send a token
  to public repos that didn't need one — leaking a credential to a
  request the server hadn't actually challenged.

This change makes git-sync follow git's own HTTP auth flow:

- `auth.Resolve` no longer consults the credential helper. Anonymous
  (or explicit token / Entire DB token) is what comes back.
- `HTTPConn` gains a `CredentialHelper` interface. On a 401 it calls
  `Lookup`, retries the request with the returned credentials, and
  stores the auth on the conn so follow-up `PostRPC` calls reuse it.
- `auth.GitCredentialHelper` shells out to `git credential fill /
  approve / reject` with `GIT_TERMINAL_PROMPT=0`, so a misconfigured
  helper fails fast rather than blocking on a tty prompt.
- On a successful retry we tell the helper `approve`; on 401 *or* 403
  (Cloudflare-style "Invalid or expired token") we tell it `reject` so
  stale credentials self-heal across runs.

Tests cover the full lifecycle: anonymous success skips the helper,
401 triggers Lookup + retry + Approve, retry-still-401 and retry-403
both trigger Reject, helper-with-no-credentials surfaces the original
401 cleanly, and explicit auth disables the helper fallback entirely.

Closes #63.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 84b7af2c6388
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 15b80ac. Configure here.

Comment thread internal/gitproto/smarthttp.go Outdated
c.CredentialHelper.Reject(ctx, c.EndpointURL, user, pass)
case res.StatusCode >= http.StatusOK && res.StatusCode < http.StatusMultipleChoices:
c.Auth = retryAuth
c.CredentialHelper.Approve(ctx, c.EndpointURL, user, pass)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Premature credential helper approve

Medium Severity

After a 401 retry, Approve and c.Auth are applied as soon as the retry HTTP status is 2xx, before httpError completes the success path and before the response is checked for the expected Git advertisement content-type or read/size limits. A misleading 2xx (wrong type or body) can persist credentials in the helper and on the connection even though RequestInfoRefs still returns an error.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 15b80ac. Configure here.

Comment thread internal/auth/auth.go Outdated
// Helper exited non-zero — typically means "no credentials found"
// or "terminal prompts disabled" (when no helper has creds). Treat
// both as "no credentials available" so the original 401 surfaces.
return "", "", false, nil //nolint:nilerr // intentional: swallow helper failure as "no credentials"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cancel masked as HTTP 401

Medium Severity

After a 401, GitCredentialHelper.Lookup runs git credential fill with the sync request context. If that context is cancelled or times out while the subprocess runs, every failure from GitCredentialCommand is turned into ok=false, so RequestInfoRefs falls through and reports the original HTTP 401 instead of context.Canceled or context.DeadlineExceeded.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 15b80ac. Configure here.

Soph and others added 5 commits May 22, 2026 17:41
- Drop internal basicAuth duplicate; use transporthttp.BasicAuth directly
  in the gitproto retry path (the package already pulls in go-git types
  via the AuthMethod interface contract).
- Collapse GitCredentialHelper.Approve/Reject into a single signal()
  helper; they only differed in the op string.
- Type the credential op as auth.CredentialOp with named constants
  (CredentialOpFill / Approve / Reject), removing magic strings from
  production code and the integration test switch.
- Collapse fakeCredentialHelper's six counters/last-X fields into a
  single calls []credCall slice with count(op)/last(op) accessors.
- Add a newTestConn(t, rt) helper to deduplicate the 8 new tests.
- Trim narrating comments throughout (per code review). Kept the
  CredentialHelper interface contract, the GIT_TERMINAL_PROMPT=0
  rationale, the 403-Cloudflare anecdote, and the explicit-auth-wins
  invariant — these encode non-obvious WHY. Trimmed the rest.

No behaviour change; full test suite + race detector + golangci-lint
pass clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 4f0fc9b99fdc
Two correctness gaps caught in review:

1. After a 3xx on /info/refs, the host that actually returns 401 is in
   res.Request.URL, which can differ from c.EndpointURL. Querying the
   helper with c.EndpointURL fetched (and on success approved)
   credentials under the wrong key — a miss on the next run, or a
   poisoned key if the retry happened to work via a follow-up redirect.
   New challengeURLFor() preserves the original repo path but swaps in
   the post-redirect scheme/host before consulting the helper.

2. Helper fallback existed only on GET /info/refs. Servers that allow
   anonymous discovery but require auth on the actual pack POST (e.g.
   Gerrit anonymous-readable + authenticated push) would fail hard.
   PostRPCStreamBody now does the same lookup → retry → approve/reject
   dance, gated on the body being io.Seeker so we can rewind it for the
   second attempt. PostRPC / PostRPCStream always pass bytes.NewReader,
   which is seekable; a caller that hands in a raw non-seekable Reader
   sees the 401 surface as-is (documented in the doc comment).

The retry lifecycle is now in tryHelperRetry, shared between GET and
POST. Four new tests cover redirect-host keying and the POST 401-retry
permutations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 991254d4f73a
The previous commit's io.Seeker gate didn't help the production push
path. internal/gitproto/push.go builds the receive-pack body as
io.MultiReader(header, packData), where packData is the live pipe from
upload-pack. That body satisfies neither io.Seeker nor any other
replayable contract, so PostRPCStreamBody's on-the-fly 401 retry was a
no-op for real pushes.

Fix: add HTTPConn.EnsureAuthForService(ctx, service) and call it from
sendReceivePack before the body is constructed. It issues an anonymous
GET to /<service>; if the server 401s, the helper is consulted, retried
with credentials, and (on a non-401/403 response) c.Auth is stored so
the streaming POST that follows is pre-authenticated.

The probe handles 2xx, 404, 405 etc. all as "credentials accepted" —
any non-401/403 means the server didn't reject the creds we sent. 405
is the typical response for GET /git-receive-pack on smart-HTTP servers,
so this is the common shape.

Limitation made explicit in the doc comment: servers that allow GET
anonymously but only 401 on POST will slip past this probe. For those,
callers must pass explicit credentials (--target-token).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: f6b8e1586278
The previous probe logic ran a second authenticated GET /<service> and
approved the credentials whenever that response wasn't 401/403. But many
servers return 405 to GET /git-receive-pack without ever validating the
Authorization header — so a 405 with attached credentials proves
nothing. Approving in that case was a false positive: stale helper creds
got blessed, c.Auth was set, and the streaming POST that followed
skipped the helper retry path. The push then failed with no way to
recover the bad helper state.

Fix: the probe is now strictly anonymous. Its only job is to detect
whether the server requires auth here (the 401 signal). If it does, the
helper-supplied credentials are attached to c.Auth tentatively and
recorded as pendingHelperCreds. The next real operation
(PostRPCStreamBody or RequestInfoRefs) calls resolvePendingHelperCreds
on its response: Approve on 2xx, Reject + clear c.Auth on 401/403,
no-op otherwise. So helper state only changes when the real operation
provides a definitive signal — which is what git itself does.

Six new/updated tests:
- TentativelyAttachesHelperCredsOnAnonymous401: c.Auth set, no Approve
- 405ProbeWithCredsDoesNotPoisonHelper: explicit regression for the bug
- RealPostApprovesTentativeCreds: 2xx on the real POST → Approve
- RealPostRejectsTentativeCreds: 401 on the real POST → Reject + clear
- NoHelperIsNoOp, AnonymousServiceLeavesAuthNil: existing semantics preserved

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: ce0a3f0780c7
GET /git-receive-pack doesn't reliably exercise the same auth path as
the real push. Servers that 404/405 the GET method while requiring auth
on POST were slipping past EnsureAuthForService — the streaming push
then 401'd with no way to retry. Cloudflare-style "Invalid or expired
token" servers fall in this bucket, as do some Gerrit configurations.

Two changes:

1. Probe with POST and the smart-HTTP flush packet "0000" as body —
   a valid no-op (zero ref updates, zero pack data) by spec. The auth
   layer challenges this POST identically to the real push, so the 401
   signal is reliable. On the anonymous-allowed case the server
   processes a no-op that touches no ref state.

2. Lookup helper credentials *before* probing, and skip the probe
   entirely when the helper has nothing to attach. Without this every
   anonymous sync would do a wasted no-op POST per push. With it, only
   syncs that have a configured helper with stored credentials for the
   host pay the round-trip cost.

cmd/git-sync's TestMain now overrides auth.GitCredentialCommand with a
"no helper configured" stub. Without isolation, the developer's local
credential store (osxkeychain etc.) could return cached credentials for
127.0.0.1 left over from a previous test run, turning the probe into a
real POST and inflating receive-pack POST counts. The internal/syncer
integration tests are unaffected — their server filters by metricPack
which the probe's no-op body doesn't carry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Entire-Checkpoint: 47657919e49a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

When source-url is a public GitLab repository, git-sync sync enforces authentication.

1 participant