-
Notifications
You must be signed in to change notification settings - Fork 775
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adds support for GitHub App authentication (#753)
* Adds support for GitHub App authentication * Cherry-pick 8621445 * Replace deprecated golang.org/x/oauth2/jws
- Loading branch information
Showing
13 changed files
with
448 additions
and
10 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}" | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |
Oops, something went wrong.