cmd/auth/gitauth: add a reference GOAUTH implementation using 'git credential fill'

Updates golang/go#26232

Change-Id: Ic22722756df1e66551222a8834a152519cb39e24
Reviewed-on: https://go-review.googlesource.com/c/tools/+/161667
Run-TryBot: Bryan C. Mills <bcmills@google.com>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Jay Conrod <jayconrod@google.com>
diff --git a/cmd/auth/gitauth/gitauth.go b/cmd/auth/gitauth/gitauth.go
new file mode 100644
index 0000000..b481fe6
--- /dev/null
+++ b/cmd/auth/gitauth/gitauth.go
@@ -0,0 +1,149 @@
+// Copyright 2019 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// gitauth uses 'git credential' to implement the GOAUTH protocol described in
+// https://golang.org/issue/26232. It expects an absolute path to the working
+// directory for the 'git' command as the first command-line argument.
+//
+// Example GOAUTH usage:
+// 	export GOAUTH="gitauth $HOME"
+//
+// See https://git-scm.com/docs/gitcredentials or run 'man gitcredentials' for
+// information on how to configure 'git credential'.
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"log"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+)
+
+func main() {
+	if len(os.Args) < 2 || !filepath.IsAbs(os.Args[1]) {
+		fmt.Fprintf(os.Stderr, "usage: %s WORKDIR [URL]", os.Args[0])
+		os.Exit(2)
+	}
+
+	log.SetPrefix("gitauth: ")
+
+	if len(os.Args) != 3 {
+		// No explicit URL was passed on the command line, but 'git credential'
+		// provides no way to enumerate existing credentials.
+		// Wait for a request for a specific URL.
+		return
+	}
+
+	u, err := url.ParseRequestURI(os.Args[2])
+	if err != nil {
+		log.Fatalf("invalid request URI (%v): %q\n", err, os.Args[1])
+	}
+
+	var (
+		prefix     *url.URL
+		lastHeader http.Header
+		lastStatus = http.StatusUnauthorized
+	)
+	for lastStatus == http.StatusUnauthorized {
+		cmd := exec.Command("git", "credential", "fill")
+
+		// We don't want to execute a 'git' command in an arbitrary directory, since
+		// that opens up a number of config-injection attacks (for example,
+		// https://golang.org/issue/29230). Instead, we have the user configure a
+		// directory explicitly on the command line.
+		cmd.Dir = os.Args[1]
+
+		cmd.Stdin = strings.NewReader(fmt.Sprintf("url=%s\n", u))
+		cmd.Stderr = os.Stderr
+		out, err := cmd.Output()
+		if err != nil {
+			log.Fatalf("'git credential fill' failed: %v\n", err)
+		}
+
+		prefix = new(url.URL)
+		var username, password string
+		lines := strings.Split(string(out), "\n")
+		for _, line := range lines {
+			frags := strings.SplitN(line, "=", 2)
+			if len(frags) != 2 {
+				continue // Ignore unrecognized response lines.
+			}
+			switch strings.TrimSpace(frags[0]) {
+			case "protocol":
+				prefix.Scheme = frags[1]
+			case "host":
+				prefix.Host = frags[1]
+			case "path":
+				prefix.Path = frags[1]
+			case "username":
+				username = frags[1]
+			case "password":
+				password = frags[1]
+			case "url":
+				// Write to a local variable instead of updating prefix directly:
+				// if the url field is malformed, we don't want to invalidate
+				// information parsed from the protocol, host, and path fields.
+				u, err := url.ParseRequestURI(frags[1])
+				if err == nil {
+					prefix = u
+				} else {
+					log.Printf("malformed URL from 'git credential fill' (%v): %q\n", err, frags[1])
+					// Proceed anyway: we might be able to parse the prefix from other fields of the response.
+				}
+			}
+		}
+
+		// Double-check that the URL Git gave us is a prefix of the one we requested.
+		if !strings.HasPrefix(u.String(), prefix.String()) {
+			log.Fatalf("requested a credential for %q, but 'git credential fill' provided one for %q\n", u, prefix)
+		}
+
+		// Send a HEAD request to try to detect whether the credential is valid.
+		// If the user just typed in a correct password and has caching enabled,
+		// we don't want to nag them for it again the next time they run a 'go' command.
+		req, err := http.NewRequest("HEAD", u.String(), nil)
+		if err != nil {
+			log.Fatalf("internal error constructing HTTP HEAD request: %v\n", err)
+		}
+		req.SetBasicAuth(username, password)
+		lastHeader = req.Header
+		resp, err := http.DefaultClient.Do(req)
+		if err != nil {
+			log.Printf("HTTPS HEAD request failed to connect: %v\n", err)
+			// Couldn't verify the credential, but we have no evidence that it is invalid either.
+			// Proceed, but don't update git's credential cache.
+			break
+		}
+		lastStatus = resp.StatusCode
+
+		if resp.StatusCode != http.StatusOK {
+			log.Printf("%s: %v %s\n", u, resp.StatusCode, http.StatusText(resp.StatusCode))
+		}
+
+		if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized {
+			// We learned something about the credential: it either worked or it was invalid.
+			// Approve or reject the credential (on a best-effort basis)
+			// so that the git credential helper can update its cache as appropriate.
+			action := "approve"
+			if resp.StatusCode != http.StatusOK {
+				action = "reject"
+			}
+			cmd = exec.Command("git", "credential", action)
+			cmd.Stderr = os.Stderr
+			cmd.Stdout = os.Stderr
+			cmd.Stdin = bytes.NewReader(out)
+			_ = cmd.Run()
+		}
+	}
+
+	// Write out the credential in the format expected by the 'go' command.
+	fmt.Printf("%s\n\n", prefix)
+	lastHeader.Write(os.Stdout)
+	fmt.Println()
+}