Skip to content

GitHub Integrations Authentication Workflow #455

Closed

Description

@bradleyfalzon

Hi,

Apart from the addition in #436, the new GitHub Integrations (https://developer.github.com/early-access/integrations/) requires a new authentication workflow, different from OAuth (https://developer.github.com/early-access/integrations/authentication/ as well as https://developer.github.com/early-access/integrations/integrations-vs-oauth-applications/).

As many APIs require this installation token (unique per installation of the integration, expires after short time period), I can easily (and do) provide a separate http.Client with a custom http.RoundTripper adding the necessary authentication headers, but, this requires a separate http.Client for every Integration installation, which means the underlying tcp connection is not being reused between installations (although I believe I can workaround that).

Further there is likely some room here to provide helpers for retrieving the required JWT tokens from a file as this is currently non-trivial and error prone (although just an example would be sufficient for most people).

I haven't developed a GitHub OAuth application before, so I'm not certain if the Integrations flow is any different, but ideally, I would like to be able to use a single client for all installations, swapping out the token on a per request basis - but I don't see how this could be possible without breaking api or not being thread safe (although I'm not certain on the safety guarantees already in place - I did look and couldn't see).

I haven't thought this through completely, and a separate client for each Integration installation maybe optimal (create another client, cache it for the life of the token), but I wanted to ask first if there has been some thought about how go-github would support with the new GitHub Integrations.

I have working prototype of a GitHub Integration and can provide samples if any maintainers want a quick start if no-one has started yet.

My current thinking (half tested):

  • Single http.Transport - ensures all derived clients reuse http connection.
  • Wrap that transport for each installation, the installationTransport adds necessary authorisation headers and is responsible for renewing tokens if they expire.
  • Pass the installationTransport into a http.Client into a github.Client.

Thanks

Activity

  1. bradleyfalzon commented on Oct 21, 2016

    @bradleyfalzon
    ContributorAuthor

    So to provide this via a http.RoundTripper, I've done:

    // acceptHeader is the GitHub Integrations Preview Accept header.
    const acceptHeader = "application/vnd.github.machine-man-preview+json"
    
    // installationTransport provides a http.RoundTripper by wrapping an existing
    // http.RoundTripper (that's shared between multiple installation transports to
    // reuse underlying http connections), but provides GitHub Integration
    // authentication as an installation.
    //
    // See https://developer.github.com/early-access/integrations/authentication/#as-an-installation
    type installationTransport struct {
            tr             http.RoundTripper // tr is the underlying roundtripper being wrapped
            keyFile        string            // keyFile is the path to GitHub Intregration's PEM encoded private key
            integrationID  int               // integrationID is the GitHub Integration's Installation ID
            installationID int               // installationID is the GitHub Integration's Installation ID
            token          *accessToken      // token is the installation's access token
    }
    
    // accessToken is an installation access token
    type accessToken struct {
            Token     string    `json:"token"`
            ExpiresAt time.Time `json:"expires_at"`
    }
    
    func (t *installationTransport) RoundTrip(req *http.Request) (*http.Response, error) {
            if t.token == nil || t.token.ExpiresAt.Add(-time.Minute).Before(time.Now()) {
                    // Token is not set or expired/nearly expired, so refresh
                    if err := t.refreshToken(); err != nil {
                            return nil, errors.Wrap(err, "could not refresh installation token")
                    }
            }
    
            req.Header.Set("Authorization", "token "+t.token.Token)
            req.Header.Set("Accept", acceptHeader)
            resp, err := t.tr.RoundTrip(req)
            return resp, err
    }
    
    func (t *installationTransport) refreshToken() error {
            // TODO these claims could probably be reused between installations before expiry
            claims := &jwt.StandardClaims{
                    IssuedAt:  time.Now().Unix(),
                    ExpiresAt: time.Now().Add(time.Minute).Unix(),
                    Issuer:    strconv.Itoa(t.integrationID),
            }
            bearer := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
    
            key, err := ioutil.ReadFile(t.keyFile)
            if err != nil {
                    return errors.Wrap(err, "could not read private key")
            }
    
            privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(key)
            if err != nil {
                    return errors.Wrap(err, "could not parse private key")
            }
    
            ss, err := bearer.SignedString(privateKey)
            if err != nil {
                    return errors.Wrap(err, "could not sign jwt")
            }
    
            req, err := http.NewRequest("POST", fmt.Sprintf("https://api.github.com/installations/%v/access_tokens", t.installationID), nil)
            if err != nil {
                    return errors.Wrap(err, "could not create request")
            }
            req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", ss))
            req.Header.Set("Accept", acceptHeader)
    
            client := &http.Client{}
            resp, err := client.Do(req)
            if err != nil {
                    return errors.Wrap(err, "could not get access_tokens")
            }
            defer resp.Body.Close()
    
            if resp.StatusCode < 200 || resp.StatusCode >= 300 {
                    return fmt.Errorf("received response status %q when fetching %v", resp.Status, req.URL)
            }
    
            if err := json.NewDecoder(resp.Body).Decode(&t.token); err != nil {
                    return err
            }
    
            return nil
    }

    Above is missing constructor as my implementation is slightly more complicated, can provide a simpler version if required.

    To use this:

    tr := http.DefaultTransport // shared for every installation
    
    // Per installation
    itr := NewInstallationTransport(IntegrationID, InstallationID, KeyFile, tr)
    client := &http.Client{Transport: itr}
    gh := github.NewClient(client)
    // gh will now manage authentication as an installation as defined by Integration Authentication

    This generally works in dev, but hasn't been tested completely (in particular, I believe the TCP connections should be reused, but I haven't actually tested this), and I haven't ran this long enough to know tokens are refreshed, there's also an opportunity to split the refresh tokens into two steps to provide the ability to authenticate as an integration too (although the only use I have for it so far it to fetch tokens), I'm not a developer and little experience with GitHub APIs and this package so there maybe better options, and it's not clear on how rate limits are being implemented by GitHub right now (per integration rate limit or per installation). Feedback appreciated.

    I'd like to know how this project would want to provide authentication for Integrations, as I know it doesn't currently provide OAuth, however, perhaps providing a similar transport would help people (there are a lot of steps specific to GitHub). If the project doesn't want to provide this (I can understand, I'll be glad to provide it as an external package).

    Thanks

  2. bradleyfalzon commented on Oct 27, 2016

    @bradleyfalzon
    ContributorAuthor

    Hi,

    I've provided this via a work-in-progress external package in the meantime: https://github.com/bradleyfalzon/ghinstallation

    Feedback welcome, but for the moment, will close the issue.

    Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions