Skip to content
Draft
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
184 changes: 151 additions & 33 deletions appsTransport.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
jwt "github.com/golang-jwt/jwt/v4"
)

type signerFunc = func() (Signer, error)

// AppsTransport provides a http.RoundTripper by wrapping an existing
// http.RoundTripper and provides GitHub Apps authentication as a
// GitHub App.
Expand All @@ -21,20 +23,25 @@ import (
//
// See https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/
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
signer Signer // signer signs JWT tokens.
appID int64 // appID is the GitHub App's ID
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
signerFunc signerFunc // used at construction time to create the signer.
signer Signer // signer signs JWT tokens.
issuer string // issuer is the ClientID (preferred) or AppID (legacy) of the GitHub App
// appID is the GitHub App's ID
//
// deprecated: kept only for backwards compatibility.
appID int64
}

// NewAppsTransportKeyFromFile returns a AppsTransport using a private key from file.
func NewAppsTransportKeyFromFile(tr http.RoundTripper, appID int64, privateKeyFile string) (*AppsTransport, error) {
privateKey, err := os.ReadFile(privateKeyFile)
if err != nil {
return nil, fmt.Errorf("could not read private key: %s", err)
}
return NewAppsTransport(tr, appID, privateKey)
return NewAppsTransportWithAllOptions(
tr,
WithAppID(appID),
WithPrivateKeyFile(privateKeyFile),
)
}

// NewAppsTransport returns a AppsTransport using private key. The key is parsed
Expand All @@ -45,40 +52,66 @@ func NewAppsTransportKeyFromFile(tr http.RoundTripper, appID int64, privateKeyFi
//
// The returned Transport's RoundTrip method is safe to be used concurrently.
func NewAppsTransport(tr http.RoundTripper, appID int64, privateKey []byte) (*AppsTransport, error) {
key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey)
if err != nil {
return nil, fmt.Errorf("could not parse private key: %s", err)
}
return NewAppsTransportFromPrivateKey(tr, appID, key), nil
return NewAppsTransportWithAllOptions(
tr,
WithAppID(appID),
WithPrivateKeyRaw(privateKey),
)
}

// NewAppsTransportFromPrivateKey returns an AppsTransport using a crypto/rsa.(*PrivateKey).
func NewAppsTransportFromPrivateKey(tr http.RoundTripper, appID int64, key *rsa.PrivateKey) *AppsTransport {
return &AppsTransport{
BaseURL: apiBaseURL,
Client: &http.Client{Transport: tr},
tr: tr,
signer: NewRSASigner(jwt.SigningMethodRS256, key),
appID: appID,
}
t, _ := NewAppsTransportWithAllOptions(
tr,
WithAppID(appID),
WithPrivateKey(key),
)

return t
}

// deprecated: use [NewAppsTransportWithAllOptions] instead, passing "WithAppID(appID)".
func NewAppsTransportWithOptions(tr http.RoundTripper, appID int64, opts ...AppsTransportOption) (*AppsTransport, error) {
t := &AppsTransport{
allOpts := make([]AppsTransportOption, 0, len(opts))
allOpts = append(allOpts, WithAppID(appID))
allOpts = append(allOpts, opts...)

return NewAppsTransportWithAllOptions(
tr,
allOpts...,
)
}

// NewAppsTransportWithAllOptions creates a new AppsTransport instance with the
// provided options. Options allow the issuer and signer to be set as needed;
// see the [With*] option methods for further details.
func NewAppsTransportWithAllOptions(tr http.RoundTripper, opts ...AppsTransportOption) (*AppsTransport, error) {
at := &AppsTransport{
BaseURL: apiBaseURL,
Client: &http.Client{Transport: tr},
tr: tr,
appID: appID,
}

for _, fn := range opts {
fn(t)
fn(at)
}

if at.issuer == "" {
return nil, errors.New("appID or clientID must be provided")
}

if at.signerFunc == nil {
return nil, errors.New("signer (or key) must be provided")
}

if t.signer == nil {
return nil, errors.New("no signer provided")
signer, err := at.signerFunc()
if err != nil {
return nil, fmt.Errorf("signer creation failed: %w", err)
}

return t, nil
at.signer = signer
at.signerFunc = nil // once it's used, it's no longer needed

return at, nil
}

// RoundTrip implements http.RoundTripper interface.
Expand All @@ -91,7 +124,7 @@ func (t *AppsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
claims := &jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(iss),
ExpiresAt: jwt.NewNumericDate(exp),
Issuer: strconv.FormatInt(t.appID, 10),
Issuer: t.issuer,
}

ss, err := t.signer.Sign(claims)
Expand All @@ -106,16 +139,101 @@ func (t *AppsTransport) RoundTrip(req *http.Request) (*http.Response, error) {
return resp, err
}

// AppID returns the appID of the transport
// AppID returns the appID of the transport. This will return 0 if the transport
// is using a ClientID.
//
// Deprecated: use [Issuer()] instead and specify the AppID as a string.
func (t *AppsTransport) AppID() int64 {
return t.appID
}

// Issuer returns the appID or clientID of the GitHub app used by this
// transport.
func (t *AppsTransport) Issuer() string {
return t.issuer
}

type AppsTransportOption func(*AppsTransport)

// WithSigner configures the AppsTransport to use the given Signer for generating JWT tokens.
// Specify the AppID of the GitHub App. Either this or ClientID must be
// specified, but ClientID is now preferred.
func WithAppID(appID int64) AppsTransportOption {
return func(at *AppsTransport) {
at.appID = appID
at.issuer = strconv.FormatInt(appID, 10)
}
}

// WithClientID specifies the ClientID of the GitHub App. Either this or AppID
// must be specified, but ClientID is now preferred.
func WithClientID(clientID string) AppsTransportOption {
return func(at *AppsTransport) {
at.issuer = clientID
}
}

// 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
at.signerFunc = func() (Signer, error) { return signer, nil }
}
}

// WithPrivateKeyFile directs the transport to use the specified private key
// file for signing requests. The private key is loaded and processed as an RSA
// key in PEM format as provided by GitHub in the application settings.
func WithPrivateKeyFile(privateKeyFile string) AppsTransportOption {
return func(at *AppsTransport) {
at.signerFunc = currySignerFunc(createSignerFromFile, privateKeyFile)
}
}

// WithPrivateKeyRaw directs the transport to use the specified private key for
// signing requests. The private key is loaded and processed as an RSA key in
// PEM format as provided by GitHub in the application settings.
func WithPrivateKeyRaw(key []byte) AppsTransportOption {
return func(at *AppsTransport) {
at.signerFunc = currySignerFunc(createSignerFromBytes, key)
}
}

// WithPrivateKeyRaw directs the transport to use the specified private key for
// signing requests. The private key is provided by GitHub in the application
// settings.
func WithPrivateKey(key *rsa.PrivateKey) AppsTransportOption {
return func(at *AppsTransport) {
at.signerFunc = currySignerFunc(createSignerFromKey, key)
}
}

func createSignerFromFile(privateKeyFile string) (Signer, error) {
privateKey, err := os.ReadFile(privateKeyFile)
if err != nil {
return nil, fmt.Errorf("could not read private key: %s", err)
}

return createSignerFromBytes(privateKey)
}

func createSignerFromBytes(rawKey []byte) (Signer, error) {
key, err := jwt.ParseRSAPrivateKeyFromPEM(rawKey)
if err != nil {
return nil, fmt.Errorf("could not parse private key: %w", err)
}

return createSignerFromKey(key)
}

func createSignerFromKey(key *rsa.PrivateKey) (Signer, error) {
return NewRSASigner(jwt.SigningMethodRS256, key), nil
}

// currySignerFunc allows the createSigner* functions to be simple, callable
// functions that can be used directly, while still supporting the function
// interface required for config options.
func currySignerFunc[T any](deferred func(T) (Signer, error), arg T) signerFunc {
return func() (Signer, error) {
return deferred(arg)
}
Comment on lines +235 to 238

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm aware this may raise some eyebrows. While they're hidden in the hairline, let me explain 😀

This is roughly equivalent to

type SignerFunc[T any] struct {
	F func(T) (Signer, error)
	T T
}

func (sf SignerFunc[T]) Create() (Signer, error) {
	return sf.F(sf.T)
}

Without this, each of the createSigner* methods would have to return a func() (Signer, error) instead, and calling from createSignerFromFile to createSignerFromBytes would need to be written as createSignerFromBytes(privateKey)().

All this said, my goal here is to write something others are happy to maintain, so I'm very open to changing this.

}