auth: defer credential helper until 401, match git's behaviour · Entire
auth: defer credential helper until 401, match git's behaviour
49a2b38→main· Soph·1mo ago·7 files·+785 added/-79 removed
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.Resolveno longer consults the credential helper. Anonymous (or explicit token / Entire DB token) is what comes back.HTTPConngains aCredentialHelperinterface. On a 401 it callsLookup, retries the request with the returned credentials, and stores the auth on the conn so follow-upPostRPCcalls reuse it.auth.GitCredentialHelpershells out togit credential fill / approve / rejectwithGIT_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 itrejectso 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.
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com
Sessions
84b7af2c6388View transcript
Changes
7
internal
auth
Mauth.go+101/-25
Mauth_test.go+227/-28
gitproto
Msmarthttp.go+95/-14
Msmarthttp_test.go+335
syncer
Mauth_test.go+8/-8
Mintegration_test.go+13/-4
Msyncer.go+6
4 unmodified lines
5
6
7
8
9
10
11
18 unmodified lines
30
31
32
32
33
34
35
36
37
38
37
38
39
40
39
40
41
42
1 unmodified line
44
45
46
48
49
50
47
48
49
50
51
52
53
54
55
11 unmodified lines
67
68
69
68
69
70
70
71
72
73
74
75
72
76
77
78
79
80
81
82
83
84
85
75
76
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
78
114
115
80
81
82
116
117
118
119
120
121
122
123
85
124
125
87
126
127
89
128
129
130
131
1 unmodified line
133
134
135
97
136
137
138
100
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
2 unmodified lines
175
176
177
109
110
178
179
180
181
182
183
184
185
186
187
188
189
4 unmodified lines
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
18 unmodified lines
}
// Resolve resolves the auth method for the given endpoint configuration.
// Order: explicit flags → Entire DB token → git credential helper → anonymous.
// Order: explicit flags → Entire DB token → anonymous (with the git credential
// helper deferred until the server returns 401, matching git's own behaviour).
func Resolve(raw Endpoint, ep *url.URL) (Method, error) {
if auth := explicitAuth(raw); auth != nil {
return auth, nil
}
if ep == nil {
return nil, nil //nolint:nilnil // nil signals no auth method found at this stage
}
if ep.Scheme != "http" && ep.Scheme != "https" {
if !isHTTPEndpoint(ep) {
return nil, nil //nolint:nilnil // nil signals no auth method found at this stage
}
}
if username, password, ok, err := LookupEntireDBCredential(raw, ep); err != nil {
} else if ok {
return &transporthttp.BasicAuth{Username: username, Password: password}, nil
}
if username, password, ok := lookupGitCredential(ep); ok {
return &transporthttp.BasicAuth{Username: username, Password: password}, nil
}
// Note: we deliberately do not consult the git credential helper here.
// Doing so eagerly would leak stored credentials to public repos that
// don't require auth, and previously caused interactive prompts when
// no helper had credentials (issue #63). The credential helper is now
// consulted on demand when an HTTP request returns 401 — see
// GitCredentialHelper, wired up by the HTTP connection layer.
return nil, nil //nolint:nilnil // nil signals no auth method found at this stage
}
11 unmodified lines
return nil
}
// GitCredentialFillCommand is replaceable for testing.
var GitCredentialFillCommand = func(ctx context.Context, input string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "git", "credential", "fill")
// newGitCredentialCmd builds the `git credential <op>` invocation used by
// GitCredentialCommand. Extracted so tests can inspect the command's
// environment without exec'ing git.
func newGitCredentialCmd(ctx context.Context, op, input string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", "credential", op)
cmd.Stdin = strings.NewReader(input)
return cmd.Output()
// Disable git's interactive terminal prompt fallback. When no credential
// helper has credentials for the host (e.g. a public repo on a server
// the user has never authenticated against), git would otherwise drop
// to an interactive username/password prompt on /dev/tty. git-sync is a
// non-interactive tool — failing here lets us cleanly surface a 401
// rather than block waiting for input. See issue #63.
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
return cmd
}
func lookupGitCredential(ep *url.URL) (string, string, bool) {
input := credentialFillInput(ep)
// GitCredentialCommand invokes `git credential <op>` with the given input
// (in the git-credential text format). op is one of "fill", "approve", or
// "reject". Replaceable for testing.
var GitCredentialCommand = func(ctx context.Context, op, input string) ([]byte, error) {
return newGitCredentialCmd(ctx, op, input).Output()
}
// GitCredentialHelper bridges Git's credential helper protocol to HTTP auth.
// It looks up credentials on demand (typically in response to a 401) and
// signals back to the helper whether the credentials worked.
//
// Implementations are best-effort: a missing or misbehaving helper must not
// fail the surrounding sync, only deny credentials. Errors from approve/reject
// are silently swallowed since those steps are advisory.
type GitCredentialHelper struct {}
// Lookup queries the git credential helper for credentials for ep. Returns
// ok=false if no credentials are available (so the caller can surface a
// clean 401 rather than block).
//
//nolint:unparam // err is always nil today but kept for the CredentialHelper interface.
func (GitCredentialHelper) Lookup(ctx context.Context, ep *url.URL) (username, password string, ok bool, err error) {
if !isHTTPEndpoint(ep) {
// git's credential helper protocol only knows about HTTP.
return "", "", false, nil
}
input := credentialInput(ep, "", "")
if input == "" {
return "", "", false
}
output, err := GitCredentialFillCommand(context.Background(), input)
if err != nil {
return "", "", false
}
values := parseCredentialOutput(output)
password := values["password"]
if password == "" {
return "", "", false
}
username := values["username"]
if username == "" {
if ep.User != nil && ep.User.Username() != "" {
username = ep.User.Username()
} else {
username = defaultGitUsername
}
}
return username, password, true
}
func credentialFillInput(ep *url.URL) string {
// isHTTPEndpoint reports whether ep is a non-nil HTTP or HTTPS endpoint.
func isHTTPEndpoint(ep *url.URL) bool {
return ep != nil && (ep.Scheme == "http" || ep.Scheme == "https")
}
// Approve tells the helper the credentials worked, so it can persist them.
// Best-effort: helper failures are swallowed.
func (GitCredentialHelper) Approve(ctx context.Context, ep *url.URL, username, password string) {
input := credentialInput(ep, username, password)
if input == "" {
return
}
_, _ = GitCredentialCommand(ctx, "approve", input) //nolint:errcheck // best-effort signal
}
// Reject tells the helper the credentials failed, so it can forget them.
// Best-effort: helper failures are swallowed.
func (GitCredentialHelper) Reject(ctx context.Context, ep *url.URL, username, password string) {
input := credentialInput(ep, username, password)
if input == "" {
return
}
_, _ = GitCredentialCommand(ctx, "reject", input) //nolint:errcheck // best-effort signal
}
// credentialInput builds a git-credential format request body for the given
// endpoint. When username/password are set, they are appended (for use with
// `git credential approve`/`reject`). When both are empty, the result is a
// query body suitable for `git credential fill`. Explicit username overrides
// any user embedded in the endpoint URL.
func credentialInput(ep *url.URL, username, password string) string {
if ep == nil || ep.Hostname() == "" {
return ""
}
var b strings.Builder
fmt.Fprintf(&b, "protocol=%s\nhost=%s\n", ep.Scheme, ep.Hostname())
if path := strings.TrimPrefix(ep.Path, "/"); path != "" {
fmt.Fprintf(&b, "path=%s\n", path)
}
if ep.User != nil && ep.User.Username() != "" {
fmt.Fprintf(&b, "username=%s\n", ep.User.Username())
}
user := username
if user == "" && ep.User != nil {
user = ep.User.Username()
}
if user != "" {
fmt.Fprintf(&b, "username=%s\n", user)
}
if password != "" {
fmt.Fprintf(&b, "password=%s\n", password)
}
b.WriteString("\n")
return b.String()
}