diff --git a/appsTransport.go b/appsTransport.go index e1424d6..317de76 100644 --- a/appsTransport.go +++ b/appsTransport.go @@ -2,6 +2,7 @@ package ghinstallation import ( "crypto/rsa" + "errors" "fmt" "io/ioutil" "net/http" @@ -23,7 +24,7 @@ type AppsTransport struct { BaseURL string // BaseURL is the scheme and host for GitHub API, defaults to https://api.github.com Client Client // Client to use to refresh tokens, defaults to http.Client with provided transport tr http.RoundTripper // tr is the underlying roundtripper being wrapped - key *rsa.PrivateKey // key is the GitHub App's private key + signer Signer // signer signs JWT tokens. appID int64 // appID is the GitHub App's ID } @@ -57,11 +58,29 @@ func NewAppsTransportFromPrivateKey(tr http.RoundTripper, appID int64, key *rsa. BaseURL: apiBaseURL, Client: &http.Client{Transport: tr}, tr: tr, - key: key, + signer: NewRSASigner(jwt.SigningMethodRS256, key), appID: appID, } } +func NewAppsTransportWithOptions(tr http.RoundTripper, appID int64, opts ...AppsTransportOption) (*AppsTransport, error) { + t := &AppsTransport{ + BaseURL: apiBaseURL, + Client: &http.Client{Transport: tr}, + tr: tr, + appID: appID, + } + for _, fn := range opts { + fn(t) + } + + if t.signer == nil { + return nil, errors.New("no signer provided") + } + + return t, nil +} + // RoundTrip implements http.RoundTripper interface. func (t *AppsTransport) RoundTrip(req *http.Request) (*http.Response, error) { // GitHub rejects expiry and issue timestamps that are not an integer, @@ -69,14 +88,13 @@ func (t *AppsTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Truncate them before passing to jwt-go. iss := time.Now().Add(-30 * time.Second).Truncate(time.Second) exp := iss.Add(2 * time.Minute) - claims := &jwt.StandardClaims{ - IssuedAt: iss.Unix(), - ExpiresAt: exp.Unix(), + claims := &jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(iss), + ExpiresAt: jwt.NewNumericDate(exp), Issuer: strconv.FormatInt(t.appID, 10), } - bearer := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - ss, err := bearer.SignedString(t.key) + ss, err := t.signer.Sign(claims) if err != nil { return nil, fmt.Errorf("could not sign jwt: %s", err) } @@ -87,3 +105,12 @@ func (t *AppsTransport) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := t.tr.RoundTrip(req) return resp, err } + +type AppsTransportOption func(*AppsTransport) + +// WithSigner configures the AppsTransport to use the given Signer for generating JWT tokens. +func WithSigner(signer Signer) AppsTransportOption { + return func(at *AppsTransport) { + at.signer = signer + } +} diff --git a/appsTransport_test.go b/appsTransport_test.go index d091487..4b80867 100644 --- a/appsTransport_test.go +++ b/appsTransport_test.go @@ -105,3 +105,35 @@ func TestJWTExpiry(t *testing.T) { t.Fatalf("error calling RoundTrip: %v", err) } } + +func TestCustomSigner(t *testing.T) { + check := RoundTrip{ + rt: func(req *http.Request) (*http.Response, error) { + h, ok := req.Header["Authorization"] + if !ok { + t.Error("Header Accept not set") + } + want := []string{"Bearer hunter2"} + if diff := cmp.Diff(want, h); diff != "" { + t.Errorf("HTTP Accept headers want->got: %s", diff) + } + return nil, nil + }, + } + + tr, err := NewAppsTransportWithOptions(check, appID, WithSigner(&noopSigner{})) + if err != nil { + t.Fatalf("NewAppsTransportWithOptions: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://example.com", new(bytes.Buffer)) + if _, err := tr.RoundTrip(req); err != nil { + t.Fatalf("error calling RoundTrip: %v", err) + } +} + +type noopSigner struct{} + +func (noopSigner) Sign(jwt.Claims) (string, error) { + return "hunter2", nil +} diff --git a/sign.go b/sign.go new file mode 100644 index 0000000..928e10e --- /dev/null +++ b/sign.go @@ -0,0 +1,33 @@ +package ghinstallation + +import ( + "crypto/rsa" + + jwt "github.com/golang-jwt/jwt/v4" +) + +// Signer is a JWT token signer. This is a wrapper around [jwt.SigningMethod] with predetermined +// key material. +type Signer interface { + // Sign signs the given claims and returns a JWT token string, as specified + // by [jwt.Token.SignedString] + Sign(claims jwt.Claims) (string, error) +} + +// RSASigner signs JWT tokens using RSA keys. +type RSASigner struct { + method *jwt.SigningMethodRSA + key *rsa.PrivateKey +} + +func NewRSASigner(method *jwt.SigningMethodRSA, key *rsa.PrivateKey) *RSASigner { + return &RSASigner{ + method: method, + key: key, + } +} + +// Sign signs the JWT claims with the RSA key. +func (s *RSASigner) Sign(claims jwt.Claims) (string, error) { + return jwt.NewWithClaims(s.method, claims).SignedString(s.key) +}