Skip to content

Commit

Permalink
Merge pull request #1 from tenjaa/github-app
Browse files Browse the repository at this point in the history
Add GitHub app authentication
  • Loading branch information
tenjaa authored Jul 19, 2021
2 parents 9ec47e2 + 2bb4f78 commit ed4bb10
Show file tree
Hide file tree
Showing 8 changed files with 264 additions and 6 deletions.
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,33 @@ Inspired by [the original][original-resource], with some important differences:

Make sure to check out [#migrating](#migrating) to learn more.

## Source Configuration
## Configuration

### Authentication
You can either use a personal access token or let the resource run as a [Github App](https://developer.github.com/apps/).

#### Personal access token
Please set the `access_token`.
If you want github-pr-resource to work with a private repository. Set `repo:full` permissions on the access token you create on GitHub. If it is a public repository, `repo:status` is enough.

#### Github App
This is useful when you are part of an organisation and do not want to share your personal access token with everyone else having access to your Secrets Manager.
Please provide `app_id` and `private_key`.

We need the following permissions:
- Contents - Read-only => required
- Pull-requests - Read-only => required
- Pull-requests - Read & write => To write/delete comments on pull requests
- Commit statuses - Read & write => To set a commit status

### Source

| Parameter | Required | Example | Description |
|-----------------------------|----------|----------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `repository` | Yes | `itsdalmo/test-repository` | The repository to target. |
| `access_token` | Yes | | A Github Access Token with repository access (required for setting status on commits). N.B. If you want github-pr-resource to work with a private repository. Set `repo:full` permissions on the access token you create on GitHub. If it is a public repository, `repo:status` is enough. |
| `access_token` | Auth | | A Github Access Token with repository access (required for setting status on commits). |
| `app_id` | Auth | `69592` | The Github App app id can be found in the settings of your app. |
| `private_key` | Auth | `-----BEGIN RSA PRIVATE KEY....` | The private key of your Github App as described [here](https://docs.github.com/en/developers/apps/authenticating-with-github-apps). |
| `v3_endpoint` | No | `https://api.github.com` | Endpoint to use for the V3 Github API (Restful). |
| `v4_endpoint` | No | `https://api.github.com/graphql` | Endpoint to use for the V4 Github API (Graphql). |
| `paths` | No | `["terraform/*/*.tf"]` | Only produce new versions if the PR includes changes to files that match one or more glob patterns or prefixes. |
Expand Down
90 changes: 90 additions & 0 deletions authentication.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package resource

import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
"net/http"
"os"
"strconv"
"strings"
"time"
)

type InstallationResponse struct {
Id int
}

type TokenResponse 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 = strings.TrimRight(s.V3Endpoint, "/")
} else {
endpoint = "https://api.github.com"
}

installationResponse := callApi("GET", endpoint+"/repos/"+s.Repository+"/installation", signedJwt)
var ir InstallationResponse
err = json.NewDecoder(installationResponse.Body).Decode(&ir)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf("Error decoding installation response with status %d", installationResponse.StatusCode))
_, _ = fmt.Fprintln(os.Stderr, installationResponse.Body)
panic(err)
}

tokenResponse := callApi("POST", endpoint+"/app/installations/"+strconv.Itoa(ir.Id)+"/access_tokens", signedJwt)

var tr TokenResponse
err = json.NewDecoder(tokenResponse.Body).Decode(&tr)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf("Error decoding token response with status %d", tokenResponse.StatusCode))
_, _ = fmt.Fprintln(os.Stderr, tokenResponse.Body)
panic(err)
}

return tr.Token, nil
}

func callApi(method string, endpoint string, signedJwt string) *http.Response {
tokenRequest, err := http.NewRequest(method, endpoint, nil)
if err != nil {
panic(err)
}
tokenRequest.Header.Add("Authorization", "Bearer "+signedJwt)
tokenRequest.Header.Add("Accept", "application/vnd.github.machine-man-preview+json")
client := &http.Client{}
response, err := client.Do(tokenRequest)

if err != nil {
panic(err)
}
return response
}
132 changes: 132 additions & 0 deletions authentication_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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) {

validInstallationResponse := `
{
"id": 9912873,
"account": {
"login": "github",
"id": 1,
"node_id": "MDEyOk9yZ2FuaXphdGlvbjE=",
"avatar_url": "https://github.com/images/error/hubot_happy.gif",
"gravatar_id": "",
"url": "https://api.github.com/orgs/github",
"html_url": "https://github.com/github",
"followers_url": "https://api.github.com/users/github/followers",
"following_url": "https://api.github.com/users/github/following{/other_user}",
"gists_url": "https://api.github.com/users/github/gists{/gist_id}",
"starred_url": "https://api.github.com/users/github/starred{/owner}{/repo}",
"subscriptions_url": "https://api.github.com/users/github/subscriptions",
"organizations_url": "https://api.github.com/users/github/orgs",
"repos_url": "https://api.github.com/orgs/github/repos",
"events_url": "https://api.github.com/orgs/github/events",
"received_events_url": "https://api.github.com/users/github/received_events",
"type": "Organization",
"site_admin": false
},
"repository_selection": "all",
"access_tokens_url": "https://api.github.com/installations/1/access_tokens",
"repositories_url": "https://api.github.com/installation/repositories",
"html_url": "https://github.com/organizations/github/settings/installations/1",
"app_id": 1,
"target_id": 1,
"target_type": "Organization",
"permissions": {
"checks": "write",
"metadata": "read",
"contents": "read"
},
"events": [
"push",
"pull_request"
],
"created_at": "2018-02-09T20:51:14Z",
"updated_at": "2018-02-09T20:51:14Z",
"single_file_name": null
}
`

validTokenResponse := `
{
"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) {
if r.RequestURI == "/repos/itsdalmo/test-repository/installation" {
assert.Equal(t, "GET", r.Method)
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, validInstallationResponse)
}

if r.RequestURI == "/app/installations/9912873/access_tokens" {
assert.Equal(t, "POST", r.Method)
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, validTokenResponse)
}
}))
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",
V3Endpoint: ts.URL,
},
expectedAccessToken: "v1.b71be873ad96e64a84025ae7bee7694a99cb4ba9",
},
{
description: "handle trailing slash",
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",
V3Endpoint: ts.URL + "/",
},
expectedAccessToken: "v1.b71be873ad96e64a84025ae7bee7694a99cb4ba9",
},
}
for _, tt := range tests {
t.Run(tt.description, func(t *testing.T) {
got, err := resource.GenerateAccessToken(&tt.source, time.Unix(1592691442, 0))
assert.Nil(t, err)
assert.Equal(t, tt.expectedAccessToken, got)
})
}
}
7 changes: 6 additions & 1 deletion git.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
)

// Git interface for testing purposes.
Expand All @@ -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
Expand Down
7 changes: 6 additions & 1 deletion github.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"path"
"strconv"
"strings"
"time"

"github.com/google/go-github/v28/github"
"github.com/shurcooL/githubv4"
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
6 changes: 4 additions & 2 deletions models.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
type Source struct {
Repository string `json:"repository"`
AccessToken string `json:"access_token"`
AppId string `json:"app_id"`
PrivateKey string `json:"private_key"`
V3Endpoint string `json:"v3_endpoint"`
V4Endpoint string `json:"v4_endpoint"`
Paths []string `json:"paths"`
Expand All @@ -31,8 +33,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 == "") {
return errors.New("access_token or app_id and private_key must be set")
}
if s.Repository == "" {
return errors.New("repository must be set")
Expand Down

0 comments on commit ed4bb10

Please sign in to comment.