From edb77477c0622e586baa391c939835ed13825a4d Mon Sep 17 00:00:00 2001 From: Torben Neufeldt Date: Sat, 20 Jun 2020 20:21:25 +0200 Subject: [PATCH] Add Github App authentication --- authentication.go | 65 +++++++++++++++++++++++++++++++++++++++ authentication_test.go | 69 ++++++++++++++++++++++++++++++++++++++++++ git.go | 7 ++++- github.go | 7 ++++- go.mod | 1 + go.sum | 2 ++ models.go | 7 +++-- 7 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 authentication.go create mode 100644 authentication_test.go diff --git a/authentication.go b/authentication.go new file mode 100644 index 00000000..58afc767 --- /dev/null +++ b/authentication.go @@ -0,0 +1,65 @@ +package resource + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" + "net/http" + "time" +) + +type Response struct { + Token string +} + +func GenerateAccessToken(s *Source, now time.Time) (string, error) { + if s.AccessToken != "" { + return s.AccessToken, nil + } + + decode, _ := pem.Decode([]byte(s.PrivateKey)) + key, _ := x509.ParsePKCS1PrivateKey(decode.Bytes) + sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT")) + if err != nil { + panic(err) + } + + cl := jwt.Claims{ + Issuer: s.AppId, + IssuedAt: jwt.NewNumericDate(now), + Expiry: jwt.NewNumericDate(now.Add(9 * time.Minute)), + } + signedJwt, err := jwt.Signed(sig).Claims(cl).CompactSerialize() + if err != nil { + panic(err) + } + + var endpoint string + if s.V3Endpoint != "" { + endpoint = s.V3Endpoint + } else { + endpoint = "https://api.github.com" + } + request, err := http.NewRequest("POST", endpoint+"/app/installations/"+s.InstallationId+"/access_tokens", nil) + if err != nil { + panic(err) + } + request.Header.Add("Authorization", "Bearer "+signedJwt) + request.Header.Add("Accept", "application/vnd.github.machine-man-preview+json") + client := &http.Client{} + response, err := client.Do(request) + + if err != nil { + panic(err) + } + + var r Response + err = json.NewDecoder(response.Body).Decode(&r) + if err != nil { + panic(err) + } + + return r.Token, nil +} diff --git a/authentication_test.go b/authentication_test.go new file mode 100644 index 00000000..95c9ecef --- /dev/null +++ b/authentication_test.go @@ -0,0 +1,69 @@ +package resource_test + +import ( + "fmt" + "github.com/stretchr/testify/assert" + resource "github.com/telia-oss/github-pr-resource" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestGenerateAccessToken(t *testing.T) { + + validResponse := ` +{ + "token": "v1.b71be873ad96e64a84025ae7bee7694a99cb4ba9", + "expires_at": "2020-06-21T00:03:29Z", + "permissions": { + "checks": "write", + "contents": "read", + "metadata": "read", + "pull_requests": "write" + }, + "repository_selection": "selected" +} +` + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "/app/installations/9912873/access_tokens", r.RequestURI) + assert.Equal(t, "application/vnd.github.machine-man-preview+json", r.Header.Get("Accept")) + assert.Equal(t, "Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1OTI2OTE5ODIsImlhdCI6MTU5MjY5MTQ0MiwiaXNzIjoiNjk1OTIifQ.H_a6i7TpaGOsaoliH_i7AT5UMwM9LqO21lEFYiZ96_H15cEF6D_kyZrcHyinP2fSC8rX_OQ-DvPsehTNTtfOhgM-nsdgg-gTzdCSlASgc00sGhw_pjBFwHpD6V1NQojc82L8SAR9Bg75g0xVlQ_dAR_Lbtmk252X_AlabRAfdSchK_GVdc3kSEbp28lc87EF7J_lFdRCNuVi1xcLFOXPmMeu4epSBf1ZMtuts28C7iqaI4QJ9keaGFug1wpL-WLDcFbvmB2nJhBYN9tArGM0ZHZ5i4EhFyFjGpBwTyo5P7WY7P3zYtz36gwgntYRtPPivcFQ-wUWuvMpL6vKd-Pp8w", r.Header.Get("Authorization")) + _, _ = fmt.Fprintln(w, validResponse) + })) + defer ts.Close() + + tests := []struct { + description string + source resource.Source + expectedAccessToken string + }{ + { + description: "return given access token", + source: resource.Source{ + Repository: "itsdalmo/test-repository", + AccessToken: "oauthtoken", + }, + expectedAccessToken: "oauthtoken", + }, + { + description: "create access token", + source: resource.Source{ + Repository: "itsdalmo/test-repository", + AppId: "69592", + PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAv6oJaa+0BCOT0dITtJK6oPfrE2R0Iynofj/a/vq4rfAw2MJp\nXA5l6WbwmQhevSm8sIYNb2T32qLyRsVXwBo1J8ovIoTXiPdsV41D19tytjctnf+u\n0LncTo2JJR3ik7/Ynu5Id4zjnsn1pYqwLX4EFxgCuEKwdZutPyiY2J7wATfsTtOJ\nsLF8idQijG23i5Obs6AWCZcHOhvdgfYAUOxLv2WRkCG5O1aXYa6nqVn0AgRgKjaJ\nnAENEoG7O9OEWIcUiG30riQouxMHfj0bATCvYoj7a2tvn4CUqk+SBODyh3Fvi0oC\na/NCBYFeK/5uUNyXQHqWy7xEVRef5lC0XbrUVwIDAQABAoIBAFDdqhkIQ/iXFjAp\n5ZyDZ/CwiWNmN8X6UZiq0nhQSolA1SsvY4qunHsMrqiyql4/dNg5xwNf419A7t3D\nN5HavOCr4pU63UFxuyl5dc1mTpDo2PtXvGdec8BE4T9iy40xHXF48eRW8la1uUn+\nKPUYvRsNS2B46sDETSVfuJV1AahRN6aD2WnzQ7wB+S/mqsPXqy+S2zobnU70Wzmt\nhYQzsuIY+BkfOCS565guYvJt66wRGi/NsnybC6z3iRZxZygtKorMKXPjmtdoyCZ3\njkOHhXV5XH8Ldut+1mycg+6c+dTZ9RTrAzo4ouofptm9+ZNlv1aK7HrDDKYQ/x/z\n70hhIfkCgYEA7aNt7db3S6sTEA+yC5DLxxkFIK7K8qxdP6vE087fdHO+ik1LvyI+\ni5Wqj/fT2d/lYR+cbH/zy2JBy5RWfdVJ9HBW/eZ9RqQx8ry7lE5MqbwU6y+d2bCF\nAjffx3yJK1aljuVLdGeu8abYsusUQUhbslZJNg6Ar7z+FUHaChJI0KsCgYEAznk4\nx+5PtvIWj6aXTJiqtofnOtHXXxrq5alzBErvDBHHzP9/ZCucHxJZ4zqHWduj2+9b\nJUgk2BH3+wFMVKpcdTld2iTFWGaFsArTJkE4SBQGx3zcdsoESOnu/DrOTFNu+LiV\nhLzb9CHKM91fIet8MYYxKiyH09+Mi7xBtw8WQwUCgYAH/tCrCOmHHTll9/E4nGWO\nzFO01syzP4NfqgrUSYiRJXfKtXEP/Dn4fk+fymnRUcwo6WRc7i0osaSfEd2bHDsB\nw2nZ3xBl+Q5JKXpyMfQ4XcCibRa1hU/kVDbuQk1nLOIjHandP8POE5wE4Q3saF/V\nbzvFWtWPlB9EXdPVNOpIQwKBgHkJEsIQ72XdUGBxVewu6pQJ4wDWFhzIWL68sJHp\no2w92BRSCkmcTu7gARV1L/b7DHlXPOUD/6UyE15vCmHvZDfLozrHp3AE2YWzMsgQ\nH4ARTVAP3+U603wytkfh6SFRH5JqEiw30fCxBimVMbleo/UcJyID7LPFLkyT1SoM\njA5JAoGACEhaHTXWFYXv+eTJUXFcwhDZ5sRvQYRCTLGSv746lr+SpZ02bEcRvaTH\nv0K1Hph6OzhcCO27VdimngnzsoXoa2OXicWpdjX3hqhXMvwspe9a1y9u7LPZwqJH\n9Kc7VaZP1iS4EIxEc+qx38HqdeiUBcqTMRam6k3mSwactBKCKDI=\n-----END RSA PRIVATE KEY-----\n", + InstallationId: "9912873", + V3Endpoint: ts.URL, + }, + expectedAccessToken: "v1.b71be873ad96e64a84025ae7bee7694a99cb4ba9", + }, + } + for _, tt := range tests { + t.Run(tt.description, func(t *testing.T) { + got, _ := resource.GenerateAccessToken(&tt.source, time.Unix(1592691442, 0)) + assert.Equal(t, tt.expectedAccessToken, got) + }) + } +} diff --git a/git.go b/git.go index 53f339d4..b9f1f8ad 100644 --- a/git.go +++ b/git.go @@ -11,6 +11,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) // Git interface for testing purposes. @@ -34,8 +35,12 @@ func NewGitClient(source *Source, dir string, output io.Writer) (*GitClient, err if source.DisableGitLFS { os.Setenv("GIT_LFS_SKIP_SMUDGE", "true") } + accessToken, err := GenerateAccessToken(source, time.Now()) + if err != nil { + return nil, err + } return &GitClient{ - AccessToken: source.AccessToken, + AccessToken: accessToken, Directory: dir, Output: output, }, nil diff --git a/github.go b/github.go index ab10cbdc..30f0afd5 100644 --- a/github.go +++ b/github.go @@ -11,6 +11,7 @@ import ( "path" "strconv" "strings" + "time" "github.com/google/go-github/v28/github" "github.com/shurcooL/githubv4" @@ -57,8 +58,12 @@ func NewGithubClient(s *Source) (*GithubClient, error) { ctx = context.TODO() } + accessToken, err := GenerateAccessToken(s, time.Now()) + if err != nil { + return nil, err + } client := oauth2.NewClient(ctx, oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: s.AccessToken}, + &oauth2.Token{AccessToken: accessToken}, )) var v3 *github.Client diff --git a/go.mod b/go.mod index ef1f5ca4..032fafd7 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d golang.org/x/tools v0.0.0-20200423205358-59e73619c742 // indirect google.golang.org/appengine v1.6.6 // indirect + gopkg.in/square/go-jose.v2 v2.5.1 ) go 1.14 diff --git a/go.sum b/go.sum index 82ec24e7..59661cb9 100644 --- a/go.sum +++ b/go.sum @@ -117,6 +117,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= diff --git a/models.go b/models.go index 9e4e7b1c..126b09a2 100644 --- a/models.go +++ b/models.go @@ -13,6 +13,9 @@ import ( type Source struct { Repository string `json:"repository"` AccessToken string `json:"access_token"` + AppId string `json:"app_id"` + PrivateKey string `json:"private_key"` + InstallationId string `json:"installation_id"` V3Endpoint string `json:"v3_endpoint"` V4Endpoint string `json:"v4_endpoint"` Paths []string `json:"paths"` @@ -31,8 +34,8 @@ type Source struct { // Validate the source configuration. func (s *Source) Validate() error { - if s.AccessToken == "" { - return errors.New("access_token must be set") + if s.AccessToken == "" && (s.AppId == "" || s.PrivateKey == "" || s.InstallationId == "") { + return errors.New("access_token or app_id and private_key and installation_id must be set") } if s.Repository == "" { return errors.New("repository must be set")