From 956d4bb9cf47718cdb24f6e34990df47d73b1a69 Mon Sep 17 00:00:00 2001 From: Sam Thanawalla Date: Tue, 13 Aug 2024 17:40:46 +0000 Subject: [PATCH] cmd/go: add user provided auth mode for GOAUTH 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 Reviewed-by: Michael Matloob LUCI-TryBot-Result: Go LUCI --- src/cmd/go/alldocs.go | 78 +++++++- src/cmd/go/internal/auth/auth.go | 111 ++++++------ src/cmd/go/internal/auth/auth_test.go | 6 +- src/cmd/go/internal/auth/gitauth.go | 28 +-- src/cmd/go/internal/auth/userauth.go | 136 ++++++++++++++ src/cmd/go/internal/auth/userauth_test.go | 169 ++++++++++++++++++ src/cmd/go/internal/help/helpdoc.go | 74 +++++++- src/cmd/go/internal/web/http.go | 4 +- src/cmd/go/main.go | 1 + .../go/testdata/script/goauth_userauth.txt | 79 ++++++++ 10 files changed, 603 insertions(+), 83 deletions(-) create mode 100644 src/cmd/go/internal/auth/userauth.go create mode 100644 src/cmd/go/internal/auth/userauth_test.go create mode 100644 src/cmd/go/testdata/script/goauth_userauth.txt diff --git a/src/cmd/go/alldocs.go b/src/cmd/go/alldocs.go index 6e7324345d..7621bb86af 100644 --- a/src/cmd/go/alldocs.go +++ b/src/cmd/go/alldocs.go @@ -43,6 +43,7 @@ // cache build and test caching // environment environment variables // filetype file types +// goauth GOAUTH environment variable // go.mod the go.mod file // gopath GOPATH environment variable // goproxy module proxy protocol @@ -2275,12 +2276,8 @@ // The architecture, or processor, for which to compile code. // Examples are amd64, 386, arm, ppc64. // GOAUTH -// A semicolon-separated list of authentication commands for go-import and -// HTTPS module mirror interactions. Currently supports -// "off" (disables authentication), -// "netrc" (uses credentials from NETRC or the .netrc file in your home directory), -// "git dir" (runs 'git credential fill' in dir and uses its credentials). -// The default is netrc. +// Controls authentication for go-import and HTTPS module mirror interactions. +// See 'go help goauth'. // GOBIN // The directory where 'go install' will install a command. // GOCACHE @@ -2511,6 +2508,75 @@ // line comment. See the go/build package documentation for // more details. // +// # GOAUTH environment variable +// +// GOAUTH is a semicolon-separated list of authentication commands for go-import and +// HTTPS module mirror interactions. The default is netrc. +// +// The supported authentication commands are: +// +// off +// +// Disables authentication. +// +// netrc +// +// Uses credentials from NETRC or the .netrc file in your home directory. +// +// git dir +// +// Runs 'git credential fill' in dir and uses its credentials. The +// go command will run 'git credential approve/reject' to update +// the credential helper's cache. +// +// command +// +// Executes the given command (a space-separated argument list) and attaches +// the provided headers to HTTPS requests. +// The command must produce output in the following format: +// Response = { CredentialSet } . +// CredentialSet = URLLine { URLLine } BlankLine { HeaderLine } BlankLine . +// URLLine = /* URL that starts with "https://" */ '\n' . +// HeaderLine = /* HTTP Request header */ '\n' . +// BlankLine = '\n' . +// +// Example: +// https://example.com/ +// https://example.net/api/ +// +// Authorization: Basic +// +// https://another-example.org/ +// +// Example: Data +// +// If the server responds with any 4xx code, the go command will write the +// following to the programs' stdin: +// Response = StatusLine { HeaderLine } BlankLine . +// StatusLine = Protocol Space Status '\n' . +// Protocol = /* HTTP protocol */ . +// Space = ' ' . +// Status = /* HTTP status code */ . +// BlankLine = '\n' . +// HeaderLine = /* HTTP Response's header */ '\n' . +// +// Example: +// HTTP/1.1 401 Unauthorized +// Content-Length: 19 +// Content-Type: text/plain; charset=utf-8 +// Date: Thu, 07 Nov 2024 18:43:09 GMT +// +// Note: at least for HTTP 1.1, the contents written to stdin can be parsed +// as an HTTP response. +// +// Before the first HTTPS fetch, the go command will invoke each GOAUTH +// command in the list with no additional arguments and no input. +// If the server responds with any 4xx code, the go command will invoke the +// GOAUTH commands again with the URL as an additional command-line argument +// and the HTTP Response to the program's stdin. +// If the server responds with an error again, the fetch fails: a URL-specific +// GOAUTH will only be attempted once per fetch. +// // # The go.mod file // // A module version is defined by a tree of source files, with a go.mod diff --git a/src/cmd/go/internal/auth/auth.go b/src/cmd/go/internal/auth/auth.go index 7d8eea07e1..dc9c7f58bb 100644 --- a/src/cmd/go/internal/auth/auth.go +++ b/src/cmd/go/internal/auth/auth.go @@ -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") } @@ -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) @@ -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") } @@ -103,28 +94,37 @@ 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) } @@ -132,29 +132,36 @@ func runGoAuth(client *http.Client, prefix string) { } } -// 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) } } diff --git a/src/cmd/go/internal/auth/auth_test.go b/src/cmd/go/internal/auth/auth_test.go index 493c72421b..c7b4851e28 100644 --- a/src/cmd/go/internal/auth/auth_test.go +++ b/src/cmd/go/internal/auth/auth_test.go @@ -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) { @@ -34,7 +34,7 @@ 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) { @@ -42,7 +42,7 @@ func TestCredentialCacheDelete(t *testing.T) { } // 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 { diff --git a/src/cmd/go/internal/auth/gitauth.go b/src/cmd/go/internal/auth/gitauth.go index 54a4d02412..b28cb54453 100644 --- a/src/cmd/go/internal/auth/gitauth.go +++ b/src/cmd/go/internal/auth/gitauth.go @@ -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. @@ -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 { @@ -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. @@ -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 { diff --git a/src/cmd/go/internal/auth/userauth.go b/src/cmd/go/internal/auth/userauth.go new file mode 100644 index 0000000000..0e54a83e31 --- /dev/null +++ b/src/cmd/go/internal/auth/userauth.go @@ -0,0 +1,136 @@ +// 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. + +// Package auth provides access to user-provided authentication credentials. +package auth + +import ( + "bufio" + "bytes" + "cmd/internal/quoted" + "fmt" + "io" + "maps" + "net/http" + "net/textproto" + "os/exec" + "strings" +) + +// runAuthCommand executes a user provided GOAUTH command, parses its output, and +// returns a mapping of prefix → http.Header. +// It uses the client to verify the credential and passes the status to the +// command's stdin. +// res is used for the GOAUTH command's stdin. +func runAuthCommand(command string, url string, res *http.Response) (map[string]http.Header, error) { + if command == "" { + panic("GOAUTH invoked an empty authenticator command:" + command) // This should be caught earlier. + } + cmd, err := buildCommand(command) + if err != nil { + return nil, err + } + if url != "" { + cmd.Args = append(cmd.Args, url) + } + cmd.Stderr = new(strings.Builder) + if res != nil && writeResponseToStdin(cmd, res) != nil { + return nil, fmt.Errorf("could not run command %s: %v\n%s", command, err, cmd.Stderr) + } + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("could not run command %s: %v\n%s", command, err, cmd.Stderr) + } + credentials, err := parseUserAuth(bytes.NewReader(out)) + if err != nil { + return nil, fmt.Errorf("cannot parse output of GOAUTH command %s: %v", command, err) + } + return credentials, nil +} + +// parseUserAuth parses the output from a GOAUTH command and +// returns a mapping of prefix → http.Header without the leading "https://" +// or an error if the data does not follow the expected format. +// Returns an nil error and an empty map if the data is empty. +// See the expected format in 'go help goauth'. +func parseUserAuth(data io.Reader) (map[string]http.Header, error) { + credentials := make(map[string]http.Header) + reader := textproto.NewReader(bufio.NewReader(data)) + for { + // Return the processed credentials if the reader is at EOF. + if _, err := reader.R.Peek(1); err == io.EOF { + return credentials, nil + } + urls, err := readURLs(reader) + if err != nil { + return nil, err + } + if len(urls) == 0 { + return nil, fmt.Errorf("invalid format: expected url prefix") + } + mimeHeader, err := reader.ReadMIMEHeader() + if err != nil { + return nil, err + } + header := http.Header(mimeHeader) + // Process the block (urls and headers). + credentialMap := mapHeadersToPrefixes(urls, header) + maps.Copy(credentials, credentialMap) + } +} + +// readURLs reads URL prefixes from the given reader until an empty line +// is encountered or an error occurs. It returns the list of URLs or an error +// if the format is invalid. +func readURLs(reader *textproto.Reader) (urls []string, err error) { + for { + line, err := reader.ReadLine() + if err != nil { + return nil, err + } + trimmedLine := strings.TrimSpace(line) + if trimmedLine != line { + return nil, fmt.Errorf("invalid format: leading or trailing white space") + } + if strings.HasPrefix(line, "https://") { + urls = append(urls, line) + } else if line == "" { + return urls, nil + } else { + return nil, fmt.Errorf("invalid format: expected url prefix or empty line") + } + } +} + +// mapHeadersToPrefixes returns a mapping of prefix → http.Header without +// the leading "https://". +func mapHeadersToPrefixes(prefixes []string, header http.Header) map[string]http.Header { + prefixToHeaders := make(map[string]http.Header, len(prefixes)) + for _, p := range prefixes { + p = strings.TrimPrefix(p, "https://") + prefixToHeaders[p] = header.Clone() // Clone the header to avoid sharing + } + return prefixToHeaders +} + +func buildCommand(command string) (*exec.Cmd, error) { + words, err := quoted.Split(command) + if err != nil { + return nil, fmt.Errorf("cannot parse GOAUTH command %s: %v", command, err) + } + cmd := exec.Command(words[0], words[1:]...) + return cmd, nil +} + +// writeResponseToStdin writes the HTTP response to the command's stdin. +func writeResponseToStdin(cmd *exec.Cmd, res *http.Response) error { + var output strings.Builder + output.WriteString(res.Proto + " " + res.Status + "\n") + if err := res.Header.Write(&output); err != nil { + return err + } + output.WriteString("\n") + cmd.Stdin = strings.NewReader(output.String()) + return nil +} diff --git a/src/cmd/go/internal/auth/userauth_test.go b/src/cmd/go/internal/auth/userauth_test.go new file mode 100644 index 0000000000..91a5bb76ec --- /dev/null +++ b/src/cmd/go/internal/auth/userauth_test.go @@ -0,0 +1,169 @@ +// Copyright 2018 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. + +package auth + +import ( + "net/http" + "reflect" + "strings" + "testing" +) + +func TestParseUserAuth(t *testing.T) { + data := `https://example.com + +Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l +Authorization: Basic jpvcGVuc2VzYW1lYWxhZGRpb + +https://hello.com + +Authorization: Basic GVuc2VzYW1lYWxhZGRpbjpvc +Authorization: Basic 1lYWxhZGRplW1lYWxhZGRpbs +Data: Test567 + +` + // Build the expected header + header1 := http.Header{ + "Authorization": []string{ + "Basic YWxhZGRpbjpvcGVuc2VzYW1l", + "Basic jpvcGVuc2VzYW1lYWxhZGRpb", + }, + } + header2 := http.Header{ + "Authorization": []string{ + "Basic GVuc2VzYW1lYWxhZGRpbjpvc", + "Basic 1lYWxhZGRplW1lYWxhZGRpbs", + }, + "Data": []string{ + "Test567", + }, + } + credentials, err := parseUserAuth(strings.NewReader(data)) + if err != nil { + t.Errorf("parseUserAuth(%s): %v", data, err) + } + gotHeader, ok := credentials["example.com"] + if !ok || !reflect.DeepEqual(gotHeader, header1) { + t.Errorf("parseUserAuth(%s):\nhave %q\nwant %q", data, gotHeader, header1) + } + gotHeader, ok = credentials["hello.com"] + if !ok || !reflect.DeepEqual(gotHeader, header2) { + t.Errorf("parseUserAuth(%s):\nhave %q\nwant %q", data, gotHeader, header2) + } +} + +func TestParseUserAuthInvalid(t *testing.T) { + testCases := []string{ + // Missing new line after url. + `https://example.com +Authorization: Basic AVuc2VzYW1lYWxhZGRpbjpvc + +`, + // Missing url. + `Authorization: Basic AVuc2VzYW1lYWxhZGRpbjpvc + +`, + // Missing url. + `https://example.com + +Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l +Authorization: Basic jpvcGVuc2VzYW1lYWxhZGRpb + +Authorization: Basic GVuc2VzYW1lYWxhZGRpbjpvc +Authorization: Basic 1lYWxhZGRplW1lYWxhZGRpbs +Data: Test567 + +`, + // Wrong order. + `Authorization: Basic AVuc2VzYW1lYWxhZGRpbjpvc + +https://example.com + +`, + // Missing new lines after URL. + `https://example.com +`, + // Missing new line after empty header. + `https://example.com + +`, + // Missing new line between blocks. + `https://example.com + +Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l +Authorization: Basic jpvcGVuc2VzYW1lYWxhZGRpb +https://hello.com + +Authorization: Basic GVuc2VzYW1lYWxhZGRpbjpvc +Authorization: Basic 1lYWxhZGRplW1lYWxhZGRpbs +Data: Test567 + +`, + } + for _, tc := range testCases { + if credentials, err := parseUserAuth(strings.NewReader(tc)); err == nil { + t.Errorf("parseUserAuth(%s) should have failed, but got: %v", tc, credentials) + } + } +} + +func TestParseUserAuthDuplicated(t *testing.T) { + data := `https://example.com + +Authorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l +Authorization: Basic jpvcGVuc2VzYW1lYWxhZGRpb + +https://example.com + +Authorization: Basic GVuc2VzYW1lYWxhZGRpbjpvc +Authorization: Basic 1lYWxhZGRplW1lYWxhZGRpbs +Data: Test567 + +` + // Build the expected header + header := http.Header{ + "Authorization": []string{ + "Basic GVuc2VzYW1lYWxhZGRpbjpvc", + "Basic 1lYWxhZGRplW1lYWxhZGRpbs", + }, + "Data": []string{ + "Test567", + }, + } + credentials, err := parseUserAuth(strings.NewReader(data)) + if err != nil { + t.Errorf("parseUserAuth(%s): %v", data, err) + } + gotHeader, ok := credentials["example.com"] + if !ok || !reflect.DeepEqual(gotHeader, header) { + t.Errorf("parseUserAuth(%s):\nhave %q\nwant %q", data, gotHeader, header) + } +} + +func TestParseUserAuthEmptyHeader(t *testing.T) { + data := "https://example.com\n\n\n" + // Build the expected header + header := http.Header{} + credentials, err := parseUserAuth(strings.NewReader(data)) + if err != nil { + t.Errorf("parseUserAuth(%s): %v", data, err) + } + gotHeader, ok := credentials["example.com"] + if !ok || !reflect.DeepEqual(gotHeader, header) { + t.Errorf("parseUserAuth(%s):\nhave %q\nwant %q", data, gotHeader, header) + } +} + +func TestParseUserAuthEmpty(t *testing.T) { + data := `` + // Build the expected header + credentials, err := parseUserAuth(strings.NewReader(data)) + if err != nil { + t.Errorf("parseUserAuth(%s) should have succeeded", data) + } + if credentials == nil { + t.Errorf("parseUserAuth(%s) should have returned a non-nil credential map, but got %v", data, credentials) + } +} diff --git a/src/cmd/go/internal/help/helpdoc.go b/src/cmd/go/internal/help/helpdoc.go index 12a12afe41..2bf3680c35 100644 --- a/src/cmd/go/internal/help/helpdoc.go +++ b/src/cmd/go/internal/help/helpdoc.go @@ -501,12 +501,8 @@ General-purpose environment variables: The architecture, or processor, for which to compile code. Examples are amd64, 386, arm, ppc64. GOAUTH - A semicolon-separated list of authentication commands for go-import and - HTTPS module mirror interactions. Currently supports - "off" (disables authentication), - "netrc" (uses credentials from NETRC or the .netrc file in your home directory), - "git dir" (runs 'git credential fill' in dir and uses its credentials). - The default is netrc. + Controls authentication for go-import and HTTPS module mirror interactions. + See 'go help goauth'. GOBIN The directory where 'go install' will install a command. GOCACHE @@ -982,3 +978,69 @@ has a term for a Go major release, the language version used when compiling the file will be the minimum version implied by the build constraint. `, } + +var HelpGoAuth = &base.Command{ + UsageLine: "goauth", + Short: "GOAUTH environment variable", + Long: ` +GOAUTH is a semicolon-separated list of authentication commands for go-import and +HTTPS module mirror interactions. The default is netrc. + +The supported authentication commands are: + +off + Disables authentication. +netrc + Uses credentials from NETRC or the .netrc file in your home directory. +git dir + Runs 'git credential fill' in dir and uses its credentials. The + go command will run 'git credential approve/reject' to update + the credential helper's cache. +command + Executes the given command (a space-separated argument list) and attaches + the provided headers to HTTPS requests. + The command must produce output in the following format: + Response = { CredentialSet } . + CredentialSet = URLLine { URLLine } BlankLine { HeaderLine } BlankLine . + URLLine = /* URL that starts with "https://" */ '\n' . + HeaderLine = /* HTTP Request header */ '\n' . + BlankLine = '\n' . + + Example: + https://example.com/ + https://example.net/api/ + + Authorization: Basic + + https://another-example.org/ + + Example: Data + + If the server responds with any 4xx code, the go command will write the + following to the programs' stdin: + Response = StatusLine { HeaderLine } BlankLine . + StatusLine = Protocol Space Status '\n' . + Protocol = /* HTTP protocol */ . + Space = ' ' . + Status = /* HTTP status code */ . + BlankLine = '\n' . + HeaderLine = /* HTTP Response's header */ '\n' . + + Example: + HTTP/1.1 401 Unauthorized + Content-Length: 19 + Content-Type: text/plain; charset=utf-8 + Date: Thu, 07 Nov 2024 18:43:09 GMT + + Note: at least for HTTP 1.1, the contents written to stdin can be parsed + as an HTTP response. + +Before the first HTTPS fetch, the go command will invoke each GOAUTH +command in the list with no additional arguments and no input. +If the server responds with any 4xx code, the go command will invoke the +GOAUTH commands again with the URL as an additional command-line argument +and the HTTP Response to the program's stdin. +If the server responds with an error again, the fetch fails: a URL-specific +GOAUTH will only be attempted once per fetch. +`, +} diff --git a/src/cmd/go/internal/web/http.go b/src/cmd/go/internal/web/http.go index 292cf062be..2ef8169db5 100644 --- a/src/cmd/go/internal/web/http.go +++ b/src/cmd/go/internal/web/http.go @@ -140,7 +140,7 @@ func get(security SecurityMode, url *urlpkg.URL) (*Response, error) { } if url.Scheme == "https" { // Use initial GOAUTH credentials. - auth.AddCredentials(client, req, "") + auth.AddCredentials(client, req, nil, "") } if intercepted { req.Host = req.URL.Host @@ -170,7 +170,7 @@ func get(security SecurityMode, url *urlpkg.URL) (*Response, error) { if err != nil { return nil, err } - auth.AddCredentials(client, req, url.String()) + auth.AddCredentials(client, req, res, url.String()) intercept.Request(req) res, err = client.Do(req) } diff --git a/src/cmd/go/main.go b/src/cmd/go/main.go index d519ad99cf..eedec2b962 100644 --- a/src/cmd/go/main.go +++ b/src/cmd/go/main.go @@ -76,6 +76,7 @@ func init() { help.HelpCache, help.HelpEnvironment, help.HelpFileType, + help.HelpGoAuth, modload.HelpGoMod, help.HelpGopath, modfetch.HelpGoproxy, diff --git a/src/cmd/go/testdata/script/goauth_userauth.txt b/src/cmd/go/testdata/script/goauth_userauth.txt new file mode 100644 index 0000000000..8403c37125 --- /dev/null +++ b/src/cmd/go/testdata/script/goauth_userauth.txt @@ -0,0 +1,79 @@ +# This test covers the HTTP authentication mechanism over GOAUTH by using a custom authenticator. +# See golang.org/issue/26232 + +env GOPROXY=direct +env GOSUMDB=off + +# Use a custom authenticator to provide custom credentials +mkdir $WORK/bin +env PATH=$WORK/bin${:}$PATH +cd auth +go build -o $WORK/bin/my-auth$GOEXE . +cd .. + +# Without credentials, downloading a module from a path that requires HTTPS +# basic auth should fail. +env GOAUTH=off +cp go.mod.orig go.mod +! go get vcs-test.golang.org/auth/or401 +stderr '^\tserver response: ACCESS DENIED, buddy$' +# go imports should fail as well. +! go mod tidy +stderr '^\tserver response: ACCESS DENIED, buddy$' + +# With credentials from the my-auth binary, it should succeed. +env GOAUTH='my-auth'$GOEXE' --arg1 "value with spaces"' +cp go.mod.orig go.mod +go get vcs-test.golang.org/auth/or401 +# go imports should resolve correctly as well. +go mod tidy +go list all +stdout vcs-test.golang.org/auth/or401 + +-- auth/main.go -- +package main + +import( + "bufio" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" +) + +func main() { + arg1 := flag.String("arg1", "", "") + flag.Parse() + if *arg1 != "value with spaces" { + log.Fatal("argument with spaces does not work") + } + // wait for re-invocation + if !strings.HasPrefix(flag.Arg(0), "https://vcs-test.golang.org") { + return + } + input, err := io.ReadAll(os.Stdin) + if err != nil { + log.Fatal("unexpected error while reading from stdin") + } + reader := bufio.NewReader(strings.NewReader(string(input))) + resp, err := http.ReadResponse(reader, nil) + if err != nil { + log.Fatal("could not parse HTTP response") + } + if resp.StatusCode != 401 { + log.Fatal("expected 401 error code") + } + fmt.Printf("https://vcs-test.golang.org\n\nAuthorization: Basic YWxhZGRpbjpvcGVuc2VzYW1l\n\n") +} + +-- auth/go.mod -- +module my-auth +-- go.mod.orig -- +module private.example.com +-- main.go -- +package useprivate + +import "vcs-test.golang.org/auth/or401" -- 2.48.1