Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 14 additions & 39 deletions github/apps.go
Original file line number Diff line number Diff line change
@@ -1,34 +1,32 @@
package github

import (
"crypto/x509"
"context"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"time"

"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
)

// Signer is an interface for signing JWTs.
// It allow for different implementations (e.g., using a local PEM file or delegating to AWS KMS).
type Signer interface {
SignJWT(ctx context.Context, claims jwt.Claims) (string, error)
}

// 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(apiURL *url.URL, appID, appInstallationID, pemData string) (string, error) {
appJWT, err := generateAppJWT(appID, time.Now(), []byte(pemData))
if err != nil {
return "", err
}

token, err := getInstallationAccessToken(apiURL, appJWT, appInstallationID)
func GenerateOAuthTokenFromApp(ctx context.Context, signer Signer, apiURL *url.URL, appID, appInstallationID string) (string, error) {
appJWT, err := generateAppJWT(ctx, appID, time.Now(), signer)
if err != nil {
return "", err
}

return token, nil
return getInstallationAccessToken(apiURL, appJWT, appInstallationID)
}

func getInstallationAccessToken(apiURL *url.URL, jwt, installationID string) (string, error) {
Expand Down Expand Up @@ -67,38 +65,15 @@ func getInstallationAccessToken(apiURL *url.URL, jwt, installationID string) (st
return resData.Token, nil
}

func generateAppJWT(appID string, now time.Time, pemData []byte) (string, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return "", errors.New("no decodeable PEM data found")
}

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{
func generateAppJWT(ctx context.Context, appID string, now time.Time, signer Signer) (string, error) {
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.
// 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
return signer.SignJWT(ctx, claims)
}
8 changes: 7 additions & 1 deletion github/apps_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package github

import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
Expand Down Expand Up @@ -29,7 +30,12 @@ var (
)

func TestGenerateAppJWT(t *testing.T) {
appJWT, err := generateAppJWT(testGitHubAppID, testEpochTime, testGitHubAppPrivateKeyPemData)
signer, err := NewPEMSigner(testGitHubAppPrivateKeyPemData)
if err != nil {
t.Fatalf("Failed to create PEM signer: %s", err)
}

appJWT, err := generateAppJWT(context.Background(), testGitHubAppID, testEpochTime, signer)
t.Log(appJWT)
if err != nil {
t.Logf("Failed to generate GitHub app JWT: %s", err)
Expand Down
47 changes: 32 additions & 15 deletions github/data_source_github_app_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,17 @@ func dataSourceGithubAppToken() *schema.Resource {
Description: descriptions["app_auth.installation_id"],
},
"pem_file": {
Type: schema.TypeString,
Required: true,
Description: descriptions["app_auth.pem_file"],
Type: schema.TypeString,
Optional: true,
Sensitive: true,
Description: descriptions["app_auth.pem_file"],
ExactlyOneOf: []string{"pem_file", "aws_kms_key_id"},
},
"aws_kms_key_id": {
Type: schema.TypeString,
Optional: true,
Description: descriptions["app_auth.aws_kms_key_id"],
ExactlyOneOf: []string{"pem_file", "aws_kms_key_id"},
},
"token": {
Type: schema.TypeString,
Expand All @@ -41,23 +49,32 @@ func dataSourceGithubAppToken() *schema.Resource {
func dataSourceGithubAppTokenRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics {
appID := d.Get("app_id").(string)
installationID := d.Get("installation_id").(string)
pemFile := d.Get("pem_file").(string)

// The Go encoding/pem package only decodes PEM formatted blocks
// that contain new lines. Some platforms, like Terraform Cloud,
// do not support new lines within Environment Variables.
// Any occurrence of \n in the `pem_file` argument's value
// (explicit value, or default value taken from
// GITHUB_APP_PEM_FILE Environment Variable) is replaced with an
// actual new line character before decoding.
pemFile = strings.ReplaceAll(pemFile, `\n`, "\n")
var signer Signer
var err error
if v, ok := d.GetOk("aws_kms_key_id"); ok {
signer, err = NewAWSKMSSigner(ctx, v.(string))
if err != nil {
return diag.FromErr(err)
}
} else {
// The Go encoding/pem package only decodes PEM formatted blocks
// that contain new lines. Some platforms, like Terraform Cloud,
// do not support new lines within Environment Variables.
// Any occurrence of \n in the `pem_file` argument's value is replaced
// with an actual new line character before decoding.
pemFile := strings.ReplaceAll(d.Get("pem_file").(string), `\n`, "\n")
signer, err = NewPEMSigner([]byte(pemFile))
if err != nil {
return diag.FromErr(err)
}
}

token, err := GenerateOAuthTokenFromApp(meta.(*Owner).v3client.BaseURL, appID, installationID, pemFile)
token, err := GenerateOAuthTokenFromApp(ctx, signer, meta.(*Owner).v3client.BaseURL, appID, installationID)
if err != nil {
return diag.FromErr(err)
}
err = d.Set("token", token)
if err != nil {
if err := d.Set("token", token); err != nil {
return diag.FromErr(err)
}
d.SetId("id")
Expand Down
40 changes: 28 additions & 12 deletions github/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,19 @@ func Provider() *schema.Provider {
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"],
Type: schema.TypeString,
Optional: true,
Sensitive: true,
DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PEM_FILE", nil),
Description: descriptions["app_auth.pem_file"],
ExactlyOneOf: []string{"app_auth.0.pem_file", "app_auth.0.aws_kms_key_id"},
},
"aws_kms_key_id": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_AWS_KMS_KEY_ID", nil),
Description: descriptions["app_auth.aws_kms_key_id"],
ExactlyOneOf: []string{"app_auth.0.pem_file", "app_auth.0.aws_kms_key_id"},
},
},
},
Expand Down Expand Up @@ -324,7 +332,8 @@ func init() {
"`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 contents.",
"app_auth.pem_file": "The GitHub App PEM file contents. Exactly one of `pem_file` or `aws_kms_key_id` must be set.",
"app_auth.aws_kms_key_id": "The AWS KMS key ID or ARN of an RSA key used to sign GitHub App JWTs. Exactly one of `pem_file` or `aws_kms_key_id` must be set.",
"write_delay_ms": "Amount of time in milliseconds to sleep in between writes to GitHub API. " +
"Defaults to 1000ms or 1s if not set.",
"read_delay_ms": "Amount of time in milliseconds to sleep in between non-write requests to GitHub API. " +
Expand Down Expand Up @@ -381,7 +390,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc {
if appAuth, ok := d.Get("app_auth").([]any); ok && len(appAuth) > 0 && appAuth[0] != nil {
appAuthAttr := appAuth[0].(map[string]any)

var appID, appInstallationID, appPemFile string
var appID, appInstallationID string

if v, ok := appAuthAttr["id"].(string); ok && v != "" {
appID = v
Expand All @@ -395,25 +404,32 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc {
return nil, wrapErrors([]error{fmt.Errorf("app_auth.installation_id must be set and contain a non-empty value")})
}

if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" {
var signer Signer
if v, ok := appAuthAttr["aws_kms_key_id"].(string); ok && v != "" {
signer, err = NewAWSKMSSigner(ctx, v)
if err != nil {
return nil, wrapErrors([]error{err})
}
} else if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" {
// The Go encoding/pem package only decodes PEM formatted blocks
// that contain new lines. Some platforms, like Terraform Cloud,
// do not support new lines within Environment Variables.
// Any occurrence of \n in the `pem_file` argument's value
// (explicit value, or default value taken from
// GITHUB_APP_PEM_FILE Environment Variable) is replaced with an
// actual new line character before decoding.
appPemFile = strings.ReplaceAll(v, `\n`, "\n")
} else {
return nil, wrapErrors([]error{fmt.Errorf("app_auth.pem_file must be set and contain a non-empty value")})
signer, err = NewPEMSigner([]byte(strings.ReplaceAll(v, `\n`, "\n")))
if err != nil {
return nil, wrapErrors([]error{err})
}
}

apiPath := ""
if isGHES {
apiPath = GHESRESTAPIPath
}

appToken, err := GenerateOAuthTokenFromApp(baseURL.JoinPath(apiPath), appID, appInstallationID, appPemFile)
appToken, err := GenerateOAuthTokenFromApp(ctx, signer, baseURL.JoinPath(apiPath), appID, appInstallationID)
if err != nil {
return nil, wrapErrors([]error{err})
}
Expand Down
104 changes: 104 additions & 0 deletions github/signers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package github

import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/kms"
kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types"
"github.com/go-jose/go-jose/v3"
"github.com/go-jose/go-jose/v3/jwt"
)

// PEMSigner signs JWTs using a local RSA private key in PEM format.
type PEMSigner struct {
signer jose.Signer
}

// NewPEMSigner creates a PEMSigner from a PKCS1 PEM-encoded RSA private key.
func NewPEMSigner(pemData []byte) (*PEMSigner, error) {
block, _ := pem.Decode(pemData)
if block == nil {
return nil, errors.New("no decodeable PEM data found")
}

privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, err
}

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

return &PEMSigner{signer: signer}, nil
}

func (s *PEMSigner) SignJWT(_ context.Context, claims jwt.Claims) (string, error) {
return jwt.Signed(s.signer).Claims(claims).CompactSerialize()
}

// AWSKMSClient abstracts the AWS KMS Sign API for testability.
type AWSKMSClient interface {
Sign(ctx context.Context, params *kms.SignInput, optFns ...func(*kms.Options)) (*kms.SignOutput, error)
}

// AWSKMSSigner signs JWTs by delegating to AWS KMS.
// The private key never leaves the KMS boundary.
type AWSKMSSigner struct {
client AWSKMSClient
keyID string
}

// NewAWSKMSSigner creates an AWSKMSSigner using the default AWS credential chain.
func NewAWSKMSSigner(ctx context.Context, keyID string) (*AWSKMSSigner, error) {
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load AWS config: %w", err)
}
return &AWSKMSSigner{
client: kms.NewFromConfig(cfg),
keyID: keyID,
}, nil
}

func (s *AWSKMSSigner) SignJWT(ctx context.Context, claims jwt.Claims) (string, error) {
headerJSON, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
if err != nil {
return "", err
}

claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", err
}

signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." +
base64.RawURLEncoding.EncodeToString(claimsJSON)

digest := sha256.Sum256([]byte(signingInput))

out, err := s.client.Sign(ctx, &kms.SignInput{
KeyId: aws.String(s.keyID),
Message: digest[:],
MessageType: kmstypes.MessageTypeDigest,
SigningAlgorithm: kmstypes.SigningAlgorithmSpecRsassaPkcs1V15Sha256,
})
if err != nil {
return "", fmt.Errorf("KMS signing failed: %w", err)
}

return signingInput + "." + base64.RawURLEncoding.EncodeToString(out.Signature), nil
}
14 changes: 14 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ module github.com/integrations/terraform-provider-github/v6
go 1.26

require (
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.15
github.com/aws/aws-sdk-go-v2/service/kms v1.50.4
github.com/go-jose/go-jose/v3 v3.0.4
github.com/google/go-github/v84 v84.0.0
github.com/google/uuid v1.6.0
Expand All @@ -19,6 +22,17 @@ require (
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/agext/levenshtein v1.2.2 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
Expand Down
Loading