Skip to content

Commit

Permalink
Allow JWT signing method to be configurable.
Browse files Browse the repository at this point in the history
This change creates a new `Signer` interface which encapsulates
jwt.SigningMethod + the key material use to sign JWT tokens.

This allows clients to do is modify how JWT tokens are
signed by passing in their own Signer. In particular, I'm interested in
coupling this with something like
https://github.com/golang-jwt/jwt#extensions to allow for JWT signing
backed by KMS systems (Vault, Cloud KMS, etc).

Also introduces a new `AppsTransportOptions` to make it easier to make
new transport creation options without needing to make new funcs each
time. For now only added `WithSigner`, but we could easily extend this
out to other config options (Client, BaseURL, etc.)
  • Loading branch information
wlynch committed Apr 7, 2023
1 parent 3b8f8c5 commit 8121e40
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 7 deletions.
41 changes: 34 additions & 7 deletions appsTransport.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package ghinstallation

import (
"crypto/rsa"
"errors"
"fmt"
"io/ioutil"
"net/http"
Expand All @@ -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
}

Expand Down Expand Up @@ -57,26 +58,43 @@ 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,
// while the jwt-go library serializes to fractional timestamps.
// 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)
}
Expand All @@ -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
}
}
32 changes: 32 additions & 0 deletions appsTransport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
33 changes: 33 additions & 0 deletions sign.go
Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit 8121e40

Please sign in to comment.