From d4e00ab2e4a421d6395364a21e3f49c440333168 Mon Sep 17 00:00:00 2001 From: Albert Lloveras Date: Sat, 3 Apr 2021 21:32:26 +1100 Subject: [PATCH 1/3] Adds support for GitHub App authentication --- github/apps.go | 100 +++++++++++++++++ github/apps_test.go | 138 ++++++++++++++++++++++++ github/provider.go | 81 +++++++++++++- github/test-fixtures/README.md | 32 +++++- github/test-fixtures/github-app-key.pem | 15 +++ github/test-fixtures/github-app-key.pub | 6 ++ 6 files changed, 362 insertions(+), 10 deletions(-) create mode 100644 github/apps.go create mode 100644 github/apps_test.go create mode 100644 github/test-fixtures/github-app-key.pem create mode 100644 github/test-fixtures/github-app-key.pub diff --git a/github/apps.go b/github/apps.go new file mode 100644 index 0000000000..ac780da825 --- /dev/null +++ b/github/apps.go @@ -0,0 +1,100 @@ +package github + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "golang.org/x/oauth2/jws" + "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 + } + + jwt, err := generateAppJWT(appID, time.Now(), pemData) + if err != nil { + return "", err + } + + token, err := getInstallationAccessToken(baseURL, jwt, 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 + } + + header := &jws.Header{ + Algorithm: "RS256", // Dictated by GitHub's API. + Typ: "JWT", // Dictated by JWT's spec. + } + + claims := &jws.ClaimSet{ + Iss: appID, + // Using now - 60s to accommodate any client/server clock drift. + Iat: now.Add(time.Duration(-60) * time.Second).Unix(), + // The JWT's lifetime can be short as it is only used immediately + // after to retrieve the installation's access token. + Exp: now.Add(time.Duration(5) * time.Minute).Unix(), + } + + token, err := jws.Encode(header, claims, privateKey) + if err != nil { + return "", err + } + + return token, nil +} diff --git a/github/apps_test.go b/github/apps_test.go new file mode 100644 index 0000000000..b262b4f789 --- /dev/null +++ b/github/apps_test.go @@ -0,0 +1,138 @@ +package github + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "golang.org/x/oauth2/jws" + "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.Time = 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() + } + + jwt, err := generateAppJWT(testGitHubAppID, testEpochTime, pemData) + t.Log(jwt) + 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(jwt, ".") + + if len(parts) != 3 { + t.Logf("The produced JWT an invalid JWT token: %s", jwt) + t.Fail() + } + }) + + t.Run("produces a jwt with expected claims", func(t *testing.T) { + claims, err := jws.Decode(jwt) + if err != nil { + t.Logf("Failed to decode generated JWT: %s", err) + t.Fail() + } + + if claims.Iss != testGitHubAppID { + t.Logf("Unexpected 'iss' claim - Expected: %s - Found: %s", testGitHubAppID, claims.Iss) + t.Fail() + } + + expectedIssuedAt := testEpochTime.Add(time.Duration(-60) * time.Second).Unix() + if claims.Iat != expectedIssuedAt { + t.Logf("Unexpected 'iss' claim - Expected: %d - Found: %s", expectedIssuedAt, claims.Iss) + t.Fail() + } + + expectedExpiration := testEpochTime.Add(time.Duration(5) * time.Minute).Unix() + if claims.Exp != expectedExpiration { + t.Logf("Unexpected 'exp' claim - Expected: %d - Found: %d", expectedExpiration, claims.Exp) + t.Fail() + } + + if claims.Sub != "" || claims.Aud != "" || claims.Typ != "" || claims.Scope != "" || claims.Prn != "" { + t.Logf("Extra claims found in JWT: %+v", claims) + t.Fail() + } + + if !t.Failed() && len(claims.PrivateClaims) != 0 { + 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() + } + + err = jws.Verify(jwt, publicKey.(*rsa.PublicKey)) + if err != nil { + t.Logf("Failed to verify JWT's signature: %s", jwt) + 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() + } +} diff --git a/github/provider.go b/github/provider.go index 2a6c82ade0..9473fc7ba2 100644 --- a/github/provider.go +++ b/github/provider.go @@ -1,6 +1,7 @@ package github import ( + "fmt" "github.com/hashicorp/terraform-plugin-sdk/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/terraform" ) @@ -39,6 +40,36 @@ func Provider() terraform.ResourceProvider { Default: false, Description: descriptions["insecure"], }, + "app_auth": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Description: descriptions["app_auth"], + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "id": { + Type: schema.TypeString, + Required: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_ID", nil), + Description: descriptions["app_auth.id"], + }, + "installation_id": { + Type: schema.TypeString, + Required: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_INSTALLATION_ID", nil), + Description: descriptions["app_auth.installation_id"], + }, + "pem_file": { + Type: schema.TypeString, + Required: true, + Sensitive: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PEM_FILE", nil), + Description: descriptions["app_auth.pem_file"], + }, + }, + }, + ConflictsWith: []string{"token"}, + }, }, ResourcesMap: map[string]*schema.Resource{ @@ -102,8 +133,8 @@ var descriptions map[string]string func init() { descriptions = map[string]string{ - "token": "The OAuth token used to connect to GitHub. " + - "`anonymous` mode is enabled if `token` is not configured.", + "token": "The OAuth token used to connect to GitHub. Anonymous mode is enabled if both `token` and " + + "`app_auth` are not set.", "base_url": "The GitHub Base API URL", @@ -114,12 +145,21 @@ func init() { "organization": "The GitHub organization name to manage. " + "Use this field instead of `owner` when managing organization accounts.", + + "app_auth": "The GitHub App credentials used to connect to GitHub. Conflicts with " + + "`token`. Anonymous mode is enabled if both `token` and `app_auth` are not set.", + "app_auth.id": "The GitHub App ID.", + "app_auth.installation_id": "The GitHub App installation instance ID.", + "app_auth.pem_file": "The GitHub App PEM file path.", } } func providerConfigure(p *schema.Provider) schema.ConfigureFunc { return func(d *schema.ResourceData) (interface{}, error) { owner := d.Get("owner").(string) + baseURL := d.Get("base_url").(string) + token := d.Get("token").(string) + insecure := d.Get("insecure").(bool) // BEGIN backwards compatibility // OwnerOrOrgEnvDefaultFunc used to be the default value for both @@ -142,10 +182,41 @@ func providerConfigure(p *schema.Provider) schema.ConfigureFunc { owner = org } + if appAuth, ok := d.Get("app_auth").([]interface{}); ok && len(appAuth) > 0 && appAuth[0] != nil { + appAuthAttr := appAuth[0].(map[string]interface{}) + + var appID, appInstallationID, appPemFile string + + if v, ok := appAuthAttr["id"].(string); ok && v != "" { + appID = v + } else { + return nil, fmt.Errorf("app_auth.id must be set and contain a non-empty value") + } + + if v, ok := appAuthAttr["installation_id"].(string); ok && v != "" { + appInstallationID = v + } else { + return nil, fmt.Errorf("app_auth.installation_id must be set and contain a non-empty value") + } + + if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" { + appPemFile = v + } else { + return nil, fmt.Errorf("app_auth.pem_file must be set and contain a non-empty value") + } + + appToken, err := GenerateOAuthTokenFromApp(baseURL, appID, appInstallationID, appPemFile) + if err != nil { + return nil, err + } + + token = appToken + } + config := Config{ - Token: d.Get("token").(string), - BaseURL: d.Get("base_url").(string), - Insecure: d.Get("insecure").(bool), + Token: token, + BaseURL: baseURL, + Insecure: insecure, Owner: owner, } diff --git a/github/test-fixtures/README.md b/github/test-fixtures/README.md index 6936d07ba3..89a01e9691 100644 --- a/github/test-fixtures/README.md +++ b/github/test-fixtures/README.md @@ -1,14 +1,15 @@ # Hi fellow bots and humans :wave: -If you're about to panic about leaked private key, then please don't. -This is purposefully exposed self-signed cert & key used in -acceptance tests of the GitHub provider. +If you're about to panic about leaked private keys, then please don't. +These are purposefully exposed cryptographic materials used in tests of +the GitHub provider. Thanks for understanding :heart: ------ +## Self-Signed Certificate (cert.pem, domain.csr, key.pem) +A self-signed cert & key used in acceptance tests of the GitHub provider. -Generated via +Generated via: ```sh openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes @@ -29,3 +30,24 @@ openssl x509 \ -in csr.pem \ -req -days 3650 -out cert.pem ``` + +## GPG Key (gpg-pubkey.asc) +Terraform's acceptance tests GPG public key. + +## SSH Public Key (id_rsa.pub) + +Terraform's acceptance tests SSH public key. + +## GitHub App Keys (github-app-key.pem, github-app-key.pub) + +Terraform's acceptance tests GitHub App public and private key. + +You can re-generate them by using the following commands: + +``` +# Private Key +openssl rsa -in github-app-key.pem -pubout -outform PEM + +# Public Key +openssl rsa -in github-app-key.pem -out github/test-fixtures/github-app-key.pub -pubout -outform PEM +``` diff --git a/github/test-fixtures/github-app-key.pem b/github/test-fixtures/github-app-key.pem new file mode 100644 index 0000000000..6efb0fd393 --- /dev/null +++ b/github/test-fixtures/github-app-key.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDOfuXClf6DL/VvR9a5iRyMeJcD5guwgkTVubcA+Phps5qlpoK3 +vLYyVuSTtR/0QYQ1lA/3Nr85jWZxCVmEHMxd1b7mZoIceUwyb2p2xKXJuT0/6K/Z +n3TWXYOzGA7R7UWI31p36S5OSqw2X8bndVkLtaGTCpsUOj+3pKyYc1P4mwIDAQAB +AoGAQ0bExKjpywQNCrx1EO3DB2EiknqYxGEs3BUtsntrM8T4VY/ydrpdAfmdbyJL +zuCpmmsq6HhFxRJ0lc9eAtT/H91cCO9ZMYNm2EsZQ14iaUsZk44o7aQBPoaKtFjO +Dn8FUNelq97fI7YR0gete4WrbrGPvvNuWsNP5SsPX9rLt4ECQQDvQl586HYQ2U7o +ZAGcxR61aA9DaBQFvHsgHLnB3s5Az/UmguBhlOdzPm3rto+p+KGZI8lHIj8XBDuT +nLhlDtqZAkEA3PGqNbtm889/2Os8YXQxnXS27mka6BjRUKqn9TAF0aEHVlqqwRzf +J1tFIFW/cSHTLOlmWz5YbT9pU9xt47yBUwJBAMkkSrtH0rondqb4LELXlRF9Ahfx +D6Qi6H/+pkvOPCdQrRBLRsfCnzHLci2PtQd39qL/6t7ac5+t90gJoRuUeUECQFXA +hets3LxsIJa0Vi8MxeTy070clhDW8QZ59c434UpHUW22qudgqUvBJMc0AKWMF0Yr +IErxm6hrIBooR45IL3kCQQCnCsJRw1BssjqD13gMdwGeueLzo4K0NXh9/2I03oDi +iY9HyQ1WM9G0r7JZQn2FrjtOliUJH05EYPOzB/6Ekx7U +-----END RSA PRIVATE KEY----- diff --git a/github/test-fixtures/github-app-key.pub b/github/test-fixtures/github-app-key.pub new file mode 100644 index 0000000000..21ab0fe74e --- /dev/null +++ b/github/test-fixtures/github-app-key.pub @@ -0,0 +1,6 @@ +-----BEGIN PUBLIC KEY----- +MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDOfuXClf6DL/VvR9a5iRyMeJcD +5guwgkTVubcA+Phps5qlpoK3vLYyVuSTtR/0QYQ1lA/3Nr85jWZxCVmEHMxd1b7m +ZoIceUwyb2p2xKXJuT0/6K/Zn3TWXYOzGA7R7UWI31p36S5OSqw2X8bndVkLtaGT +CpsUOj+3pKyYc1P4mwIDAQAB +-----END PUBLIC KEY----- From 922187b0b7515deb06dd59dce96dec6ab29f09a1 Mon Sep 17 00:00:00 2001 From: Albert Lloveras Date: Mon, 26 Apr 2021 23:49:11 +1000 Subject: [PATCH 2/3] Cherry-pick 8621445666fda9082082c0056c547f3eee23e9ea --- examples/app_authentication/README.md | 18 ++++++++++++++++++ examples/app_authentication/main.tf | 4 ++++ examples/app_authentication/outputs.tf | 0 examples/app_authentication/providers.tf | 16 ++++++++++++++++ examples/app_authentication/variables.tf | 4 ++++ 5 files changed, 42 insertions(+) create mode 100644 examples/app_authentication/README.md create mode 100644 examples/app_authentication/main.tf create mode 100644 examples/app_authentication/outputs.tf create mode 100644 examples/app_authentication/providers.tf create mode 100644 examples/app_authentication/variables.tf diff --git a/examples/app_authentication/README.md b/examples/app_authentication/README.md new file mode 100644 index 0000000000..7f2852d8dc --- /dev/null +++ b/examples/app_authentication/README.md @@ -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}" +``` diff --git a/examples/app_authentication/main.tf b/examples/app_authentication/main.tf new file mode 100644 index 0000000000..16151f804b --- /dev/null +++ b/examples/app_authentication/main.tf @@ -0,0 +1,4 @@ +resource "github_repository" "github_repository" { + name = "github_app_example" + description = "A repository created using GitHub App authentication" +} diff --git a/examples/app_authentication/outputs.tf b/examples/app_authentication/outputs.tf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/app_authentication/providers.tf b/examples/app_authentication/providers.tf new file mode 100644 index 0000000000..6b5972787d --- /dev/null +++ b/examples/app_authentication/providers.tf @@ -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" + } + } +} diff --git a/examples/app_authentication/variables.tf b/examples/app_authentication/variables.tf new file mode 100644 index 0000000000..051b73b2d3 --- /dev/null +++ b/examples/app_authentication/variables.tf @@ -0,0 +1,4 @@ +variable "owner" { + description = "GitHub owner used to configure the provider" + type = string +} From fbf9088a6ce8c5d8e6d63b581bf8b887dc30df0a Mon Sep 17 00:00:00 2001 From: Albert Lloveras Date: Tue, 27 Apr 2021 10:03:44 +1000 Subject: [PATCH 3/3] Replace deprecated golang.org/x/oauth2/jws --- github/apps.go | 27 +++++++++------- github/apps_test.go | 78 +++++++++++++++++++++++++++++++++------------ go.mod | 1 + go.sum | 2 ++ 4 files changed, 76 insertions(+), 32 deletions(-) diff --git a/github/apps.go b/github/apps.go index ac780da825..ace8c5e12e 100644 --- a/github/apps.go +++ b/github/apps.go @@ -5,7 +5,8 @@ import ( "encoding/json" "encoding/pem" "fmt" - "golang.org/x/oauth2/jws" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" "io/ioutil" "net/http" "time" @@ -19,12 +20,12 @@ func GenerateOAuthTokenFromApp(baseURL, appID, appInstallationID, appPemFile str return "", err } - jwt, err := generateAppJWT(appID, time.Now(), pemData) + appJWT, err := generateAppJWT(appID, time.Now(), pemData) if err != nil { return "", err } - token, err := getInstallationAccessToken(baseURL, jwt, appInstallationID) + token, err := getInstallationAccessToken(baseURL, appJWT, appInstallationID) if err != nil { return "", err } @@ -77,21 +78,25 @@ func generateAppJWT(appID string, now time.Time, pemData []byte) (string, error) return "", err } - header := &jws.Header{ - Algorithm: "RS256", // Dictated by GitHub's API. - Typ: "JWT", // Dictated by JWT's spec. + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: privateKey}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + + if err != nil { + return "", err } - claims := &jws.ClaimSet{ - Iss: appID, + claims := &jwt.Claims{ + Issuer: appID, // Using now - 60s to accommodate any client/server clock drift. - Iat: now.Add(time.Duration(-60) * time.Second).Unix(), + 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. - Exp: now.Add(time.Duration(5) * time.Minute).Unix(), + Expiry: jwt.NewNumericDate(now.Add(time.Duration(5) * time.Minute)), } - token, err := jws.Encode(header, claims, privateKey) + token, err := jwt.Signed(signer).Claims(claims).CompactSerialize() if err != nil { return "", err } diff --git a/github/apps_test.go b/github/apps_test.go index b262b4f789..15de2b63a4 100644 --- a/github/apps_test.go +++ b/github/apps_test.go @@ -5,7 +5,8 @@ import ( "crypto/x509" "encoding/pem" "fmt" - "golang.org/x/oauth2/jws" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" "io/ioutil" "strings" "testing" @@ -20,7 +21,7 @@ const ( ) var ( - testEpochTime time.Time = time.Unix(0, 0) + testEpochTime = time.Unix(0, 0) ) func TestGenerateAppJWT(t *testing.T) { @@ -30,52 +31,80 @@ func TestGenerateAppJWT(t *testing.T) { t.FailNow() } - jwt, err := generateAppJWT(testGitHubAppID, testEpochTime, pemData) - t.Log(jwt) + 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(jwt, ".") + parts := strings.Split(appJWT, ".") if len(parts) != 3 { - t.Logf("The produced JWT an invalid JWT token: %s", jwt) + 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) { - claims, err := jws.Decode(jwt) + tok, err := jwt.ParseSigned(appJWT) if err != nil { - t.Logf("Failed to decode generated JWT: %s", err) + t.Logf("Failed to decode JWT '%s': %s", appJWT, err) t.Fail() } - if claims.Iss != testGitHubAppID { - t.Logf("Unexpected 'iss' claim - Expected: %s - Found: %s", testGitHubAppID, claims.Iss) + claims := &jwt.Claims{} + err = tok.UnsafeClaimsWithoutVerification(claims) + if err != nil { + t.Logf("Failed to extract claims from JWT '%s': %s", appJWT, err) t.Fail() } - expectedIssuedAt := testEpochTime.Add(time.Duration(-60) * time.Second).Unix() - if claims.Iat != expectedIssuedAt { - t.Logf("Unexpected 'iss' claim - Expected: %d - Found: %s", expectedIssuedAt, claims.Iss) + if claims.Issuer != testGitHubAppID { + t.Logf("Unexpected 'iss' claim - Expected: %s - Found: %s", testGitHubAppID, claims.Issuer) t.Fail() } - expectedExpiration := testEpochTime.Add(time.Duration(5) * time.Minute).Unix() - if claims.Exp != expectedExpiration { - t.Logf("Unexpected 'exp' claim - Expected: %d - Found: %d", expectedExpiration, claims.Exp) + 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() } - if claims.Sub != "" || claims.Aud != "" || claims.Typ != "" || claims.Scope != "" || claims.Prn != "" { - t.Logf("Extra claims found in JWT: %+v", claims) + 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 !t.Failed() && len(claims.PrivateClaims) != 0 { + if claims.Subject != "" || claims.Audience != nil || claims.ID != "" || claims.NotBefore != nil { t.Logf("Extra claims found in JWT: %+v", claims) t.Fail() } @@ -95,9 +124,16 @@ func TestGenerateAppJWT(t *testing.T) { t.FailNow() } - err = jws.Verify(jwt, publicKey.(*rsa.PublicKey)) + 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 verify JWT's signature: %s", jwt) + t.Logf("Failed to decode JWT '%s': %s", appJWT, err) t.Fail() } }) diff --git a/go.mod b/go.mod index 2519f06abe..f84d4cf5d3 100644 --- a/go.mod +++ b/go.mod @@ -14,4 +14,5 @@ require ( github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d + gopkg.in/square/go-jose.v2 v2.5.1 ) diff --git a/go.sum b/go.sum index 2ef00f7aec..a8c50029d7 100644 --- a/go.sum +++ b/go.sum @@ -601,6 +601,8 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= +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.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=