github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/api/checks/jwt.go (about)

     1  // Copyright 2018 The WPT Dashboard Project. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  package checks
     6  
     7  import (
     8  	"context"
     9  	"crypto/x509"
    10  	"encoding/json"
    11  	"encoding/pem"
    12  	"errors"
    13  	"fmt"
    14  	"io"
    15  	"net/http"
    16  	"time"
    17  
    18  	jwt "github.com/golang-jwt/jwt"
    19  	"github.com/google/go-github/v47/github"
    20  	"github.com/web-platform-tests/wpt.fyi/shared"
    21  	"golang.org/x/oauth2"
    22  )
    23  
    24  func getGitHubClient(ctx context.Context, appID, installationID int64) (*github.Client, error) {
    25  	jwtClient, err := getJWTClient(ctx, appID, installationID)
    26  	if err != nil {
    27  		return nil, err
    28  	}
    29  
    30  	return github.NewClient(jwtClient), nil
    31  }
    32  
    33  // NOTE(lukebjerring): oauth2/jwt has incorrect field-names, and doesn't allow
    34  // passing in an http.Client (for GitHub's Authorization header flow), so we
    35  // are forced to copy a little code here :(.
    36  func getJWTClient(ctx context.Context, appID, installation int64) (*http.Client, error) {
    37  	ss, err := getSignedJWT(ctx, appID)
    38  	if err != nil {
    39  		return nil, err
    40  	}
    41  	tokenSource := oauth2.StaticTokenSource(
    42  		&oauth2.Token{AccessToken: ss}, // nolint:exhaustruct // WONTFIX: AccessToken only required.
    43  	)
    44  	oauthClient := oauth2.NewClient(ctx, tokenSource)
    45  
    46  	tokenURL := fmt.Sprintf("https://api.github.com/app/installations/%v/access_tokens", installation)
    47  	req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, tokenURL, nil)
    48  	req.Header.Set("Accept", "application/vnd.github.machine-man-preview+json")
    49  	resp, err := oauthClient.Do(req)
    50  	if err != nil {
    51  		return nil, fmt.Errorf("cannot fetch installation token: %w", err)
    52  	}
    53  
    54  	defer resp.Body.Close()
    55  	body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
    56  	if err != nil {
    57  		return nil, fmt.Errorf("cannot fetch installation token: %w", err)
    58  	}
    59  	if c := resp.StatusCode; c < 200 || c > 299 {
    60  		// nolint:exhaustruct // TODO: Fix exhaustruct lint error.
    61  		// Investigate which error code should be returned here.
    62  		return nil, &oauth2.RetrieveError{
    63  			Response: resp,
    64  			Body:     body,
    65  		}
    66  	}
    67  	// tokenResponse is the JSON response body.
    68  	var tokenResponse struct {
    69  		AccessToken string    `json:"token"`
    70  		ExpiresAt   time.Time `json:"expires_at"`
    71  	}
    72  	if err := json.Unmarshal(body, &tokenResponse); err != nil {
    73  		return nil, fmt.Errorf("oauth2: cannot fetch token: %w", err)
    74  	}
    75  	// nolint:exhaustruct // WONTFIX: AccessToken only required.
    76  	token := &oauth2.Token{
    77  		AccessToken: tokenResponse.AccessToken,
    78  		Expiry:      tokenResponse.ExpiresAt,
    79  	}
    80  
    81  	return oauth2.NewClient(ctx, oauth2.StaticTokenSource(token)), nil
    82  }
    83  
    84  // nolint:lll // Keep hyperlink
    85  // https://developer.github.com/apps/building-github-apps/authenticating-with-github-apps/#authenticating-as-a-github-app
    86  func getSignedJWT(ctx context.Context, appID int64) (string, error) {
    87  	ds := shared.NewAppEngineDatastore(ctx, false)
    88  	secret, err := shared.GetSecret(ds, fmt.Sprintf("github-app-private-key-%v", appID))
    89  	if err != nil {
    90  		return "", err
    91  	}
    92  	block, _ := pem.Decode([]byte(secret))
    93  	if block == nil {
    94  		return "", errors.New("Failed to decode private key")
    95  	}
    96  	key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
    97  	if err != nil {
    98  		return "", err
    99  	}
   100  
   101  	/* Create the jwt token */
   102  	now := time.Now()
   103  	claims := &jwt.StandardClaims{
   104  		IssuedAt:  now.Unix(),
   105  		ExpiresAt: now.Add(time.Minute * 10).Unix(),
   106  		Issuer:    fmt.Sprintf("%v", appID),
   107  	}
   108  
   109  	jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
   110  
   111  	return jwtToken.SignedString(key)
   112  }