Skip to content

Commit

Permalink
Add netrc-based AuthTransport (#11)
Browse files Browse the repository at this point in the history
`NetrcBasicAuthTransport` allows for Jira API authentication to be
performed using credentials sourced from a netrc file.
  • Loading branch information
chrisnovakovic authored Feb 22, 2024
1 parent b7c0ef6 commit c91ee0d
Show file tree
Hide file tree
Showing 7 changed files with 258 additions and 1 deletion.
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand All @@ -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=
141 changes: 141 additions & 0 deletions jira.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
89 changes: 89 additions & 0 deletions jira_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package jira

import (
"bytes"
"errors"
"fmt"
"io/ioutil"
"math"
Expand All @@ -12,6 +13,8 @@ import (
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
)

const (
Expand Down Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions test_data/netrc/machine.netrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
machine 127.0.0.1 login jirauser password jirapass
2 changes: 2 additions & 0 deletions test_data/netrc/no_machine.netrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
machine fake.example login u password p
machine another.fake.example jirauser password jirapass
2 changes: 2 additions & 0 deletions test_data/netrc/wrong_creds.netrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
machine 127.0.0.1 login wronguser password wrongpass
machine 127.8.8.8 login jirauser password jirapass

0 comments on commit c91ee0d

Please sign in to comment.