From c91ee0dde221ae37b5508dc298ab5ba8f99afefb Mon Sep 17 00:00:00 2001 From: Chris Novakovic Date: Thu, 22 Feb 2024 17:34:42 +0000 Subject: [PATCH] Add netrc-based `AuthTransport` (#11) `NetrcBasicAuthTransport` allows for Jira API authentication to be performed using credentials sourced from a netrc file. --- go.mod | 2 + go.sum | 22 ++++- jira.go | 141 ++++++++++++++++++++++++++++++ jira_test.go | 89 +++++++++++++++++++ test_data/netrc/machine.netrc | 1 + test_data/netrc/no_machine.netrc | 2 + test_data/netrc/wrong_creds.netrc | 2 + 7 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 test_data/netrc/machine.netrc create mode 100644 test_data/netrc/no_machine.netrc create mode 100644 test_data/netrc/wrong_creds.netrc diff --git a/go.mod b/go.mod index 1dd55df2..60d0f3d4 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,9 @@ require ( github.com/google/go-cmp v0.5.8 github.com/google/go-querystring v1.1.0 github.com/hashicorp/go-retryablehttp v0.7.5 + github.com/jdx/go-netrc v1.0.0 github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.8.4 github.com/trivago/tgo v1.0.7 golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f // indirect golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d diff --git a/go.sum b/go.sum index 2a00c9f7..73c933ad 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -15,12 +16,25 @@ github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxC github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M= github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= +github.com/jdx/go-netrc v1.0.0 h1:QbLMLyCZGj0NA8glAhxUpf1zDg6cxnWgMBbjq40W0gQ= +github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM= github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -29,3 +43,9 @@ golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d h1:SZxvLBoTP5yHO3Frd4z4vrF+DBX9vMVanchswa69toE= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/jira.go b/jira.go index 8646b73e..edb27c73 100644 --- a/jira.go +++ b/jira.go @@ -10,11 +10,14 @@ import ( "io" "net/http" "net/url" + "os" + "path/filepath" "reflect" "sort" "strings" "time" + "github.com/jdx/go-netrc" jwt "github.com/golang-jwt/jwt/v4" "github.com/google/go-querystring/query" "github.com/hashicorp/go-retryablehttp" @@ -403,6 +406,144 @@ func (t *BasicAuthTransport) transport() http.RoundTripper { return defaultTransport } +// netrcCredentials contains the login name and password for a particular machine, as +// defined by a netrc file. +type netrcCredentials struct { + Login string + Password string +} + +// NetrcBasicAuthTransport is an http.RoundTripper that authenticates all requests +// using HTTP Basic Authentication with the machine login and password sourced from a +// netrc file. +// +// In the netrc file format, machine names are hostnames - they may not contain port +// numbers or URL paths. NetrcBasicAuthTransport may therefore behave incorrectly if the +// netrc file contains credentials for multiple Jira API instances hosted on the same +// host but on different ports or at different URL paths. +// +// netrc file format reference: https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html +type NetrcBasicAuthTransport struct { + // Path is the path to the netrc file containing the Jira credentials. If nil, the + // .netrc file in the user's home directory is used. + Path *string + + // Transport is the underlying HTTP transport to use when making requests. + // If nil, defaults to a Transport that automatically retries the request on failure. + Transport http.RoundTripper + + netrcFile *netrc.Netrc + cache map[string]*netrcCredentials +} + +// DefaultNetrcBasicAuthTransport creates a NetrcBasicAuthTransport from the credentials +// stored in ~/.netrc. +func DefaultNetrcBasicAuthTransport() *NetrcBasicAuthTransport { + return &NetrcBasicAuthTransport{ + Path: nil, + cache: make(map[string]*netrcCredentials), + } +} + +// NewNetrcBasicAuthTransport creates a NetrcBasicAuthTransport from the credentials stored in the +// netrc file at the given path. +func NewNetrcBasicAuthTransport(path string) *NetrcBasicAuthTransport { + return &NetrcBasicAuthTransport{ + Path: &path, + cache: make(map[string]*netrcCredentials), + } +} + +// RoundTrip implements the RoundTripper interface. We just add the credentials and return the +// RoundTripper for this transport type. +func (t *NetrcBasicAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req2 := cloneRequest(req) // per RoundTripper contract + + creds, err := t.credentials(req.URL.Hostname()) + if err != nil { + return nil, err + } + + req2.SetBasicAuth(creds.Login, creds.Password) + return t.transport().RoundTrip(req2) +} + +func (t *NetrcBasicAuthTransport) parseNetrcFile() error { + if t.Path == nil { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get path to user's home directory: %w", err) + } + p := filepath.Join(homeDir, ".netrc") + t.Path = &p + } + + n, err := netrc.Parse(*t.Path) + if err != nil { + return fmt.Errorf("%s: parsing failure: %w", *t.Path, err) + } + t.netrcFile = n + + return nil +} + +func (t *NetrcBasicAuthTransport) credentials(host string) (*netrcCredentials, error) { + if t.netrcFile == nil { + err := t.parseNetrcFile() + if err != nil { + return nil, err + } + } + + creds, exists := t.cache[host] + if !exists { + machine := t.netrcFile.Machine(host) + if machine == nil { + return nil, fmt.Errorf("%s: no credentials for machine '%s'", *t.Path, host) + } + if machine.Get("login") == "" { + return nil, fmt.Errorf("%s: no login for machine '%s'", *t.Path, host) + } + if machine.Get("password") == "" { + return nil, fmt.Errorf("%s: no password for machine '%s'", *t.Path, host) + } + t.cache[host] = &netrcCredentials{ + Login: machine.Get("login"), + Password: machine.Get("password"), + } + creds = t.cache[host] + } + + return creds, nil +} + +// Username returns the HTTP Basic Authentication username that would be used to authenticate +// with the Jira API at the given hostname. +func (t *NetrcBasicAuthTransport) Username(host string) (string, error) { + creds, err := t.credentials(host) + if err != nil { + return "", err + } + return creds.Login, nil +} + +// Client returns an *http.Client that makes requests that are authenticated using HTTP Basic +// Authentication with credentials from a netrc file. This is a nice little bit of sugar so +// we can just get the client instead of creating the client in the calling code. +// +// If it's necessary to send more information on client init, the calling code can always skip this +// and set the transport itself. +func (t *NetrcBasicAuthTransport) Client() *http.Client { + return &http.Client{Transport: t} +} + +func (t *NetrcBasicAuthTransport) transport() http.RoundTripper { + if t.Transport != nil { + return t.Transport + } + return defaultTransport +} + // BearerAuthTransport is a http.RoundTripper that authenticates all requests // using Jira's bearer (oauth 2.0 (3lo)) based authentication. type BearerAuthTransport struct { diff --git a/jira_test.go b/jira_test.go index cfadcd7d..73dd64dd 100644 --- a/jira_test.go +++ b/jira_test.go @@ -2,6 +2,7 @@ package jira import ( "bytes" + "errors" "fmt" "io/ioutil" "math" @@ -12,6 +13,8 @@ import ( "strings" "testing" "time" + + "github.com/stretchr/testify/assert" ) const ( @@ -588,6 +591,92 @@ func TestBasicAuthTransport_transport(t *testing.T) { } } +func TestNetrcBasicAuthTransport(t *testing.T) { + username, password := "jirauser", "jirapass" + + for _, test := range []struct { + Description string + NetrcPath string + ServerError string + ClientError string + }{ + { + Description: "Missing netrc file", + NetrcPath: "test_data/netrc/nonexistent.netrc", + ClientError: "open test_data/netrc/nonexistent.netrc", + }, + { + Description: "Machine exists", + NetrcPath: "test_data/netrc/machine.netrc", + }, + { + Description: "Machine missing", + NetrcPath: "test_data/netrc/no_machine.netrc", + ClientError: "no credentials for machine", + }, + { + Description: "Machine exists, but credentials incorrect", + NetrcPath: "test_data/netrc/wrong_creds.netrc", + ServerError: "request contained wrong basic auth password", + }, + } { + setup() + + var serverErr error + testMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + u, p, ok := r.BasicAuth() + if !ok { + serverErr = errors.New("request does not contain basic auth credentials") + } + if u != username { + serverErr = fmt.Errorf("request contained wrong basic auth username: got %q, want %q", u, username) + } + if p != password { + serverErr = fmt.Errorf("request contained wrong basic auth password: got %q, want %q", p, password) + } + }) + + tp := NewNetrcBasicAuthTransport(test.NetrcPath) + client, _ := NewClient(tp.Client(), testServer.URL) + req, _ := client.NewRequest("GET", "/", nil) + _, clientErr := client.Do(req, nil) + + if test.ServerError == "" { + assert.NoError(t, serverErr) + } else { + assert.ErrorContains(t, serverErr, test.ServerError) + } + if test.ClientError == "" { + assert.NoError(t, clientErr) + } else { + assert.ErrorContains(t, clientErr, test.ClientError) + } + if test.ServerError == "" && test.ClientError == "" { + u, err := tp.Username(req.URL.Hostname()) + assert.Equal(t, username, u) + assert.NoError(t, err) + } + + teardown() + } +} + +func TestNetrcBasicAuthTransport_transport(t *testing.T) { + // default transport + tp := &NetrcBasicAuthTransport{} + if tp.transport() != defaultTransport { + t.Errorf("Expected defaultTransport to be used.") + } + + // custom transport + tp = &NetrcBasicAuthTransport{ + Transport: &http.Transport{}, + } + if tp.transport() == defaultTransport { + t.Errorf("Expected custom transport to be used.") + } +} + // Test that the cookie in the transport is the cookie returned in the header func TestCookieAuthTransport_SessionObject_Exists(t *testing.T) { setup() diff --git a/test_data/netrc/machine.netrc b/test_data/netrc/machine.netrc new file mode 100644 index 00000000..58496bc0 --- /dev/null +++ b/test_data/netrc/machine.netrc @@ -0,0 +1 @@ +machine 127.0.0.1 login jirauser password jirapass diff --git a/test_data/netrc/no_machine.netrc b/test_data/netrc/no_machine.netrc new file mode 100644 index 00000000..adb302f7 --- /dev/null +++ b/test_data/netrc/no_machine.netrc @@ -0,0 +1,2 @@ +machine fake.example login u password p +machine another.fake.example jirauser password jirapass diff --git a/test_data/netrc/wrong_creds.netrc b/test_data/netrc/wrong_creds.netrc new file mode 100644 index 00000000..3fcda7d6 --- /dev/null +++ b/test_data/netrc/wrong_creds.netrc @@ -0,0 +1,2 @@ +machine 127.0.0.1 login wronguser password wrongpass +machine 127.8.8.8 login jirauser password jirapass