Skip to content

Commit

Permalink
cmd/go: add user provided auth mode for GOAUTH
Browse files Browse the repository at this point in the history
This CL adds support for a custom authenticator as a valid GOAUTH command.
This follows the specification in
https://go.dev/issue/26232#issuecomment-461525141

For #26232

Cq-Include-Trybots: luci.golang.try:gotip-linux-amd64-longtest,gotip-windows-amd64-longtest
Change-Id: Id1d4b309f11eb9c7ce14793021a9d8caf3b192ff
Reviewed-on: https://go-review.googlesource.com/c/go/+/605298
Auto-Submit: Sam Thanawalla <[email protected]>
Reviewed-by: Michael Matloob <[email protected]>
LUCI-TryBot-Result: Go LUCI <[email protected]>
  • Loading branch information
samthanawalla authored and gopherbot committed Nov 15, 2024
1 parent 5030146 commit 956d4bb
Show file tree
Hide file tree
Showing 10 changed files with 603 additions and 83 deletions.
78 changes: 72 additions & 6 deletions src/cmd/go/alldocs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

111 changes: 59 additions & 52 deletions src/cmd/go/internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ var (
// as specified by the GOAUTH environment variable.
// It returns whether any matching credentials were found.
// req must use HTTPS or this function will panic.
func AddCredentials(client *http.Client, req *http.Request, prefix string) bool {
// res is used for the custom GOAUTH command's stdin.
func AddCredentials(client *http.Client, req *http.Request, res *http.Response, url string) bool {
if req.URL.Scheme != "https" {
panic("GOAUTH called without https")
}
Expand All @@ -37,41 +38,31 @@ func AddCredentials(client *http.Client, req *http.Request, prefix string) bool
}
// Run all GOAUTH commands at least once.
authOnce.Do(func() {
runGoAuth(client, "")
runGoAuth(client, res, "")
})
if prefix != "" {
// First fetch must have failed; re-invoke GOAUTH commands with prefix.
runGoAuth(client, prefix)
if url != "" {
// First fetch must have failed; re-invoke GOAUTH commands with url.
runGoAuth(client, res, url)
}
currentPrefix := strings.TrimPrefix(req.URL.String(), "https://")
// Iteratively try prefixes, moving up the path hierarchy.
for currentPrefix != "/" && currentPrefix != "." && currentPrefix != "" {
if loadCredential(req, currentPrefix) {
return true
}

// Move to the parent directory.
currentPrefix = path.Dir(currentPrefix)
}
return false
return loadCredential(req, req.URL.String())
}

// runGoAuth executes authentication commands specified by the GOAUTH
// environment variable handling 'off', 'netrc', and 'git' methods specially,
// and storing retrieved credentials for future access.
func runGoAuth(client *http.Client, prefix string) {
func runGoAuth(client *http.Client, res *http.Response, url string) {
var cmdErrs []error // store GOAUTH command errors to log later.
goAuthCmds := strings.Split(cfg.GOAUTH, ";")
// The GOAUTH commands are processed in reverse order to prioritize
// credentials in the order they were specified.
slices.Reverse(goAuthCmds)
for _, cmdStr := range goAuthCmds {
cmdStr = strings.TrimSpace(cmdStr)
cmdParts := strings.Fields(cmdStr)
if len(cmdParts) == 0 {
for _, command := range goAuthCmds {
command = strings.TrimSpace(command)
words := strings.Fields(command)
if len(words) == 0 {
base.Fatalf("GOAUTH encountered an empty command (GOAUTH=%s)", cfg.GOAUTH)
}
switch cmdParts[0] {
switch words[0] {
case "off":
if len(goAuthCmds) != 1 {
base.Fatalf("GOAUTH=off cannot be combined with other authentication commands (GOAUTH=%s)", cfg.GOAUTH)
Expand All @@ -85,13 +76,13 @@ func runGoAuth(client *http.Client, prefix string) {
for _, l := range lines {
r := http.Request{Header: make(http.Header)}
r.SetBasicAuth(l.login, l.password)
storeCredential([]string{l.machine}, r.Header)
storeCredential(l.machine, r.Header)
}
case "git":
if len(cmdParts) != 2 {
if len(words) != 2 {
base.Fatalf("GOAUTH=git dir method requires an absolute path to the git working directory")
}
dir := cmdParts[1]
dir := words[1]
if !filepath.IsAbs(dir) {
base.Fatalf("GOAUTH=git dir method requires an absolute path to the git working directory, dir is not absolute")
}
Expand All @@ -103,58 +94,74 @@ func runGoAuth(client *http.Client, prefix string) {
base.Fatalf("GOAUTH=git dir method requires an absolute path to the git working directory, dir is not a directory")
}

if prefix == "" {
if url == "" {
// Skip the initial GOAUTH run since we need to provide an
// explicit prefix to runGitAuth.
// explicit url to runGitAuth.
continue
}
prefix, header, err := runGitAuth(client, dir, prefix)
prefix, header, err := runGitAuth(client, dir, url)
if err != nil {
// Save the error, but don't print it yet in case another
// GOAUTH command might succeed.
cmdErrs = append(cmdErrs, fmt.Errorf("GOAUTH=%s: %v", cmdStr, err))
cmdErrs = append(cmdErrs, fmt.Errorf("GOAUTH=%s: %v", command, err))
} else {
storeCredential([]string{strings.TrimPrefix(prefix, "https://")}, header)
storeCredential(prefix, header)
}
default:
base.Fatalf("unimplemented: %s", cmdStr)
credentials, err := runAuthCommand(command, url, res)
if err != nil {
// Save the error, but don't print it yet in case another
// GOAUTH command might succeed.
cmdErrs = append(cmdErrs, fmt.Errorf("GOAUTH=%s: %v", command, err))
continue
}
for prefix := range credentials {
storeCredential(prefix, credentials[prefix])
}
}
}
// If no GOAUTH command provided a credential for the given prefix
// If no GOAUTH command provided a credential for the given url
// and an error occurred, log the error.
if cfg.BuildX && prefix != "" {
if _, ok := credentialCache.Load(prefix); !ok && len(cmdErrs) > 0 {
log.Printf("GOAUTH encountered errors for %s:", prefix)
if cfg.BuildX && url != "" {
if ok := loadCredential(&http.Request{}, url); !ok && len(cmdErrs) > 0 {
log.Printf("GOAUTH encountered errors for %s:", url)
for _, err := range cmdErrs {
log.Printf(" %v", err)
}
}
}
}

// loadCredential retrieves cached credentials for the given url prefix and adds
// loadCredential retrieves cached credentials for the given url and adds
// them to the request headers.
func loadCredential(req *http.Request, prefix string) bool {
headers, ok := credentialCache.Load(prefix)
if !ok {
return false
}
for key, values := range headers.(http.Header) {
for _, value := range values {
req.Header.Add(key, value)
func loadCredential(req *http.Request, url string) bool {
currentPrefix := strings.TrimPrefix(url, "https://")
// Iteratively try prefixes, moving up the path hierarchy.
for currentPrefix != "/" && currentPrefix != "." && currentPrefix != "" {
headers, ok := credentialCache.Load(currentPrefix)
if !ok {
// Move to the parent directory.
currentPrefix = path.Dir(currentPrefix)
continue
}
for key, values := range headers.(http.Header) {
for _, value := range values {
req.Header.Add(key, value)
}
}
return true
}
return true
return false
}

// storeCredential caches or removes credentials (represented by HTTP headers)
// associated with given URL prefixes.
func storeCredential(prefixes []string, header http.Header) {
for _, prefix := range prefixes {
if len(header) == 0 {
credentialCache.Delete(prefix)
} else {
credentialCache.Store(prefix, header)
}
func storeCredential(prefix string, header http.Header) {
// Trim "https://" prefix to match the format used in .netrc files.
prefix = strings.TrimPrefix(prefix, "https://")
if len(header) == 0 {
credentialCache.Delete(prefix)
} else {
credentialCache.Store(prefix, header)
}
}
6 changes: 3 additions & 3 deletions src/cmd/go/internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func TestCredentialCache(t *testing.T) {
for _, tc := range testCases {
want := http.Request{Header: make(http.Header)}
want.SetBasicAuth(tc.login, tc.password)
storeCredential([]string{tc.machine}, want.Header)
storeCredential(tc.machine, want.Header)
got := &http.Request{Header: make(http.Header)}
ok := loadCredential(got, tc.machine)
if !ok || !reflect.DeepEqual(got.Header, want.Header) {
Expand All @@ -34,15 +34,15 @@ func TestCredentialCacheDelete(t *testing.T) {
// Store a credential for api.github.com
want := http.Request{Header: make(http.Header)}
want.SetBasicAuth("user", "pwd")
storeCredential([]string{"api.github.com"}, want.Header)
storeCredential("api.github.com", want.Header)
got := &http.Request{Header: make(http.Header)}
ok := loadCredential(got, "api.github.com")
if !ok || !reflect.DeepEqual(got.Header, want.Header) {
t.Errorf("parseNetrc:\nhave %q\nwant %q", got.Header, want.Header)
}
// Providing an empty header for api.github.com should clear credentials.
want = http.Request{Header: make(http.Header)}
storeCredential([]string{"api.github.com"}, want.Header)
storeCredential("api.github.com", want.Header)
got = &http.Request{Header: make(http.Header)}
ok = loadCredential(got, "api.github.com")
if ok {
Expand Down
28 changes: 14 additions & 14 deletions src/cmd/go/internal/auth/gitauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ import (

const maxTries = 3

// runGitAuth retrieves credentials for the given prefix using
// runGitAuth retrieves credentials for the given url using
// 'git credential fill', validates them with a HEAD request
// (using the provided client) and updates the credential helper's cache.
// It returns the matching credential prefix, the http.Header with the
// Basic Authentication header set, or an error.
// The caller must not mutate the header.
func runGitAuth(client *http.Client, dir, prefix string) (string, http.Header, error) {
if prefix == "" {
// No explicit prefix was passed, but 'git credential'
func runGitAuth(client *http.Client, dir, url string) (string, http.Header, error) {
if url == "" {
// No explicit url was passed, but 'git credential'
// provides no way to enumerate existing credentials.
// Wait for a request for a specific prefix.
return "", nil, fmt.Errorf("no explicit prefix was passed")
// Wait for a request for a specific url.
return "", nil, fmt.Errorf("no explicit url was passed")
}
if dir == "" {
// Prevent config-injection attacks by requiring an explicit working directory.
Expand All @@ -43,18 +43,18 @@ func runGitAuth(client *http.Client, dir, prefix string) (string, http.Header, e
}
cmd := exec.Command("git", "credential", "fill")
cmd.Dir = dir
cmd.Stdin = strings.NewReader(fmt.Sprintf("url=%s\n", prefix))
cmd.Stdin = strings.NewReader(fmt.Sprintf("url=%s\n", url))
out, err := cmd.CombinedOutput()
if err != nil {
return "", nil, fmt.Errorf("'git credential fill' failed (url=%s): %w\n%s", prefix, err, out)
return "", nil, fmt.Errorf("'git credential fill' failed (url=%s): %w\n%s", url, err, out)
}
parsedPrefix, username, password := parseGitAuth(out)
if parsedPrefix == "" {
return "", nil, fmt.Errorf("'git credential fill' failed for url=%s, could not parse url\n", prefix)
return "", nil, fmt.Errorf("'git credential fill' failed for url=%s, could not parse url\n", url)
}
// Check that the URL Git gave us is a prefix of the one we requested.
if !strings.HasPrefix(prefix, parsedPrefix) {
return "", nil, fmt.Errorf("requested a credential for %s, but 'git credential fill' provided one for %s\n", prefix, parsedPrefix)
if !strings.HasPrefix(url, parsedPrefix) {
return "", nil, fmt.Errorf("requested a credential for %s, but 'git credential fill' provided one for %s\n", url, parsedPrefix)
}
req, err := http.NewRequest("HEAD", parsedPrefix, nil)
if err != nil {
Expand All @@ -69,7 +69,7 @@ func runGitAuth(client *http.Client, dir, prefix string) (string, http.Header, e
// The request is intercepted for testing purposes to simulate interactions
// with the credential helper.
intercept.Request(req)
go updateCredentialHelper(client, req, out)
go updateGitCredentialHelper(client, req, out)

// Return the parsed prefix and headers, even if credential validation fails.
// The caller is responsible for the primary validation.
Expand Down Expand Up @@ -115,10 +115,10 @@ func parseGitAuth(data []byte) (parsedPrefix, username, password string) {
return prefix.String(), username, password
}

// updateCredentialHelper validates the given credentials by sending a HEAD request
// updateGitCredentialHelper validates the given credentials by sending a HEAD request
// and updates the git credential helper's cache accordingly. It retries the
// request up to maxTries times.
func updateCredentialHelper(client *http.Client, req *http.Request, credentialOutput []byte) {
func updateGitCredentialHelper(client *http.Client, req *http.Request, credentialOutput []byte) {
for range maxTries {
release, err := base.AcquireNet()
if err != nil {
Expand Down
Loading

0 comments on commit 956d4bb

Please sign in to comment.