Skip to content

Commit

Permalink
Adds support for GitHub App authentication (#753)
Browse files Browse the repository at this point in the history
* Adds support for GitHub App authentication

* Cherry-pick 8621445

* Replace deprecated golang.org/x/oauth2/jws
  • Loading branch information
alloveras authored and Jeremy Udit committed Jun 16, 2021
1 parent 775ac55 commit 18c0ee8
Show file tree
Hide file tree
Showing 13 changed files with 448 additions and 10 deletions.
18 changes: 18 additions & 0 deletions examples/app_authentication/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# App Installation Example

This example demonstrates authenticating using a GitHub App.

The example will create a repository in the specified organization.

You may use variables passed via command line:

```console
export GITHUB_OWNER=
export GITHUB_APP_ID=
export GITHUB_APP_INSTALLATION_ID=
export GITHUB_APP_PEM_FILE=
```

```console
terraform apply -var "organization=${GITHUB_ORG}"
```
4 changes: 4 additions & 0 deletions examples/app_authentication/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
resource "github_repository" "github_repository" {
name = "github_app_example"
description = "A repository created using GitHub App authentication"
}
Empty file.
16 changes: 16 additions & 0 deletions examples/app_authentication/providers.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
provider "github" {
owner = var.owner
app_auth {
// Empty block to allow the provider configurations to be specified through
// environment variables.
// See: https://github.com/hashicorp/terraform-plugin-sdk/issues/142
}
}

terraform {
required_providers {
github = {
source = "integrations/github"
}
}
}
4 changes: 4 additions & 0 deletions examples/app_authentication/variables.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
variable "owner" {
description = "GitHub owner used to configure the provider"
type = string
}
105 changes: 105 additions & 0 deletions github/apps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package github

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

// GenerateOAuthTokenFromApp generates a GitHub OAuth access token from a set of valid GitHub App credentials. The
// returned token can be used to interact with both GitHub's REST and GraphQL APIs.
func GenerateOAuthTokenFromApp(baseURL, appID, appInstallationID, appPemFile string) (string, error) {
pemData, err := ioutil.ReadFile(appPemFile)
if err != nil {
return "", err
}

appJWT, err := generateAppJWT(appID, time.Now(), pemData)
if err != nil {
return "", err
}

token, err := getInstallationAccessToken(baseURL, appJWT, appInstallationID)
if err != nil {
return "", err
}

return token, nil
}

func getInstallationAccessToken(baseURL string, jwt string, installationID string) (string, error) {
url := fmt.Sprintf("%sapp/installations/%s/access_tokens", baseURL, installationID)

req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
return "", err
}

req.Header.Add("Accept", "application/vnd.github.v3+json")
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", jwt))

res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = res.Body.Close() }()

resBytes, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", err
}

if res.StatusCode != http.StatusCreated {
return "", fmt.Errorf("failed to create OAuth token from GitHub App: %s", string(resBytes))
}

resData := struct {
Token string `json:"token"`
}{}

err = json.Unmarshal(resBytes, &resData)
if err != nil {
return "", err
}

return resData.Token, nil
}

func generateAppJWT(appID string, now time.Time, pemData []byte) (string, error) {
block, _ := pem.Decode(pemData)
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return "", err
}

signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.RS256, Key: privateKey},
(&jose.SignerOptions{}).WithType("JWT"),
)

if err != nil {
return "", err
}

claims := &jwt.Claims{
Issuer: appID,
// Using now - 60s to accommodate any client/server clock drift.
IssuedAt: jwt.NewNumericDate(now.Add(time.Duration(-60) * time.Second)),
// The JWT's lifetime can be short as it is only used immediately
// after to retrieve the installation's access token.
Expiry: jwt.NewNumericDate(now.Add(time.Duration(5) * time.Minute)),
}

token, err := jwt.Signed(signer).Claims(claims).CompactSerialize()
if err != nil {
return "", err
}

return token, nil
}
174 changes: 174 additions & 0 deletions github/apps_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
package github

import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"gopkg.in/square/go-jose.v2"
"gopkg.in/square/go-jose.v2/jwt"
"io/ioutil"
"strings"
"testing"
"time"
)

const (
testGitHubAppID string = "123456789"
testGitHubAppInstallationID string = "987654321"
testGitHubAppPrivateKeyFile string = "test-fixtures/github-app-key.pem"
testGitHubAppPublicKeyFile string = "test-fixtures/github-app-key.pub"
)

var (
testEpochTime = time.Unix(0, 0)
)

func TestGenerateAppJWT(t *testing.T) {
pemData, err := ioutil.ReadFile(testGitHubAppPrivateKeyFile)
if err != nil {
t.Logf("Failed to read private key file '%s': %s", testGitHubAppPrivateKeyFile, err)
t.FailNow()
}

appJWT, err := generateAppJWT(testGitHubAppID, testEpochTime, pemData)
t.Log(appJWT)
if err != nil {
t.Logf("Failed to generate GitHub app JWT: %s", err)
t.FailNow()
}

t.Run("produces a properly shaped jwt", func(t *testing.T) {
parts := strings.Split(appJWT, ".")

if len(parts) != 3 {
t.Logf("Failed to produce a properly shaped jwt token: '%s'", appJWT)
t.Fail()
}
})

t.Run("produces a jwt with expected algorithm and type", func(t *testing.T) {
tok, err := jwt.ParseSigned(appJWT)
if err != nil {
t.Logf("Failed to decode JWT '%s': %s", appJWT, err)
t.Fail()
}

if len(tok.Headers) != 1 {
t.Logf("Failed to decode JWT '%s': multiple header entries were found", appJWT)
t.FailNow()
}

headers := tok.Headers[0]

expectedAlgorithm := string(jose.RS256)
if headers.Algorithm != expectedAlgorithm {
t.Logf("The generated JWT '%s' does not use the expected algorithm - Expected: %s - Found: %s", appJWT, expectedAlgorithm, headers.Algorithm)
t.Fail()
}

if value, ok := headers.ExtraHeaders[jose.HeaderType]; !ok || value != "JWT" {
t.Logf("The generated JWT '%s' does not contain the expected 'typ' header or its value isn't set to 'JWT'", appJWT)
t.Fail()
}
})

t.Run("produces a jwt with expected claims", func(t *testing.T) {
tok, err := jwt.ParseSigned(appJWT)
if err != nil {
t.Logf("Failed to decode JWT '%s': %s", appJWT, err)
t.Fail()
}

claims := &jwt.Claims{}
err = tok.UnsafeClaimsWithoutVerification(claims)
if err != nil {
t.Logf("Failed to extract claims from JWT '%s': %s", appJWT, err)
t.Fail()
}

if claims.Issuer != testGitHubAppID {
t.Logf("Unexpected 'iss' claim - Expected: %s - Found: %s", testGitHubAppID, claims.Issuer)
t.Fail()
}

expectedIssuedAt := testEpochTime.Add(time.Duration(-60) * time.Second)
if claims.IssuedAt.Time() != expectedIssuedAt {
t.Logf("Unexpected 'iss' claim - Expected: %d - Found: %d", expectedIssuedAt.Unix(), claims.IssuedAt)
t.Fail()
}

expectedExpiration := testEpochTime.Add(time.Duration(5) * time.Minute)
if claims.Expiry.Time() != expectedExpiration {
t.Logf("Unexpected 'exp' claim - Expected: %d - Found: %d", expectedExpiration.Unix(), claims.Expiry)
t.Fail()
}

if claims.Subject != "" || claims.Audience != nil || claims.ID != "" || claims.NotBefore != nil {
t.Logf("Extra claims found in JWT: %+v", claims)
t.Fail()
}
})

t.Run("produces a verifiable jwt", func(t *testing.T) {
publicKeyData, err := ioutil.ReadFile(testGitHubAppPublicKeyFile)
if err != nil {
t.Logf("Failed to read public key file '%s': %s", testGitHubAppPublicKeyFile, err)
t.FailNow()
}

block, _ := pem.Decode(publicKeyData)
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
t.Logf("Failed to decode public key file '%s': %s", testGitHubAppPublicKeyFile, err)
t.FailNow()
}

tok, err := jwt.ParseSigned(appJWT)
if err != nil {
t.Logf("Failed to decode JWT '%s': %s", appJWT, err)
t.Fail()
}

claims := &jwt.Claims{}
err = tok.Claims(publicKey.(*rsa.PublicKey), claims)
if err != nil {
t.Logf("Failed to decode JWT '%s': %s", appJWT, err)
t.Fail()
}
})
}

func TestGetInstallationAccessToken(t *testing.T) {
fakeJWT := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9" +
".eyJpc3MiOiIxMjM0NTY3ODkiLCJhdWQiOiIiLCJleHAiOjMwMCwiaWF0IjotNjB9" +
".jpx6AFGoZzHzre79JveY_nyKop11v-bLxLEMvEDrn2wDF9S1FeX-zfTiA6Xi00Akn0Wklj7OYx0wHCvi37aiD4zjp0qPz5i5V7aMrRsWsO6eCzNfY0VLuV6pX8jlAHFfo71SvpdAMWH4in8ty5bNVUMv0NmwWdlHAQ0LLIPSxE4"

expectedAccessToken := "W+2e/zjiMTweDAr2b35toCF+h29l7NW92rJIPvFrCJQK"

ts := githubApiMock([]*mockResponse{
{
ExpectedUri: fmt.Sprintf("/app/installations/%s/access_tokens", testGitHubAppInstallationID),
ExpectedHeaders: map[string]string{
"Accept": "application/vnd.github.v3+json",
"Authorization": fmt.Sprintf("Bearer %s", fakeJWT),
},

ResponseBody: fmt.Sprintf(`{"token": "%s"}`, expectedAccessToken),
StatusCode: 201,
},
})
defer ts.Close()

accessToken, err := getInstallationAccessToken(ts.URL+"/", fakeJWT, testGitHubAppInstallationID)

if err != nil {
t.Logf("Unexpected error: %s", err)
t.Fail()
}

if accessToken != expectedAccessToken {
t.Logf("Unexpected access token - Found: %s - Expected: %s", accessToken, expectedAccessToken)
t.Fail()
}
}
Loading

0 comments on commit 18c0ee8

Please sign in to comment.