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

     1  // Copyright 2019 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  //go:generate mockgen -destination sharedtest/github_oauth_mock.go -package sharedtest github.com/web-platform-tests/wpt.fyi/shared GitHubOAuth,GitHubAccessControl
     6  
     7  package shared
     8  
     9  import (
    10  	"context"
    11  	"encoding/gob"
    12  	"errors"
    13  	"fmt"
    14  	"net/http"
    15  
    16  	"github.com/google/go-github/v47/github"
    17  	"github.com/gorilla/securecookie"
    18  	"golang.org/x/oauth2"
    19  	ghOAuth "golang.org/x/oauth2/github"
    20  )
    21  
    22  func init() {
    23  	// All custom types stored in securecookie need to be registered.
    24  	gob.RegisterName("User", User{})
    25  }
    26  
    27  // User represents an authenticated GitHub user.
    28  type User struct {
    29  	GitHubHandle string `json:"github_handle,omitempty"`
    30  	GitHubEmail  string `json:"github_email,omitempty"`
    31  }
    32  
    33  // GitHubAccessControl encapsulates implementation details of access control for the wpt-metadata repository.
    34  type GitHubAccessControl interface {
    35  	// IsValid* functions also verify the access token with GitHub.
    36  	IsValidWPTMember() (bool, error)
    37  	IsValidAdmin() (bool, error)
    38  }
    39  
    40  type githubAccessControlImpl struct {
    41  	ctx   context.Context
    42  	ds    Datastore
    43  	user  *User
    44  	token string
    45  
    46  	// This is the client for the OAuth app.
    47  	oauthClientID string
    48  	oauthGHClient *github.Client
    49  
    50  	// This is the bot account client.
    51  	botClient *github.Client
    52  }
    53  
    54  // GitHubOAuth encapsulates implementation details of GitHub OAuth flow.
    55  type GitHubOAuth interface {
    56  	Context() context.Context
    57  	Datastore() Datastore
    58  	GetAccessToken() string
    59  	GetAuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string
    60  	GetUser(client *github.Client) (*github.User, error)
    61  	NewClient(oauthCode string) (*github.Client, error)
    62  	SetRedirectURL(url string)
    63  }
    64  
    65  type githubOAuthImpl struct {
    66  	ctx         context.Context
    67  	ds          Datastore
    68  	conf        *oauth2.Config
    69  	accessToken string
    70  }
    71  
    72  func (g *githubOAuthImpl) Datastore() Datastore {
    73  	return g.ds
    74  }
    75  
    76  func (g *githubOAuthImpl) Context() context.Context {
    77  	return g.ctx
    78  }
    79  
    80  func (g *githubOAuthImpl) GetAccessToken() string {
    81  	return g.accessToken
    82  }
    83  
    84  func (g *githubOAuthImpl) SetRedirectURL(url string) {
    85  	g.conf.RedirectURL = url
    86  }
    87  
    88  func (g *githubOAuthImpl) GetAuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string {
    89  	return g.conf.AuthCodeURL(state, opts...)
    90  }
    91  
    92  func (g *githubOAuthImpl) NewClient(oauthCode string) (*github.Client, error) {
    93  	token, err := g.conf.Exchange(g.ctx, oauthCode)
    94  	if err != nil {
    95  		return nil, err
    96  	}
    97  	g.accessToken = token.AccessToken
    98  
    99  	oauthClient := oauth2.NewClient(g.ctx, oauth2.StaticTokenSource(token))
   100  	client := github.NewClient(oauthClient)
   101  
   102  	return client, nil
   103  }
   104  
   105  func (g *githubOAuthImpl) GetUser(client *github.Client) (*github.User, error) {
   106  	// Passing the empty string will fetch the authenticated user.
   107  	ghUser, _, err := client.Users.Get(g.ctx, "")
   108  	if err != nil {
   109  		return nil, err
   110  	}
   111  
   112  	return ghUser, nil
   113  }
   114  
   115  // NewGitHubOAuth returns an instance of GitHubOAuth for loginHandler and oauthHandler.
   116  func NewGitHubOAuth(ctx context.Context) (GitHubOAuth, error) {
   117  	store := NewAppEngineDatastore(ctx, false)
   118  	log := GetLogger(ctx)
   119  
   120  	clientID, secret, err := getOAuthClientIDSecret(store)
   121  	if err != nil {
   122  		log.Errorf("Failed to get github-oauth-client-{id,secret}: %s", err.Error())
   123  		return nil, err
   124  	}
   125  
   126  	oauth := &oauth2.Config{
   127  		ClientID:     clientID,
   128  		ClientSecret: secret,
   129  		// (no scope) - see https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/#available-scopes
   130  		Scopes:   []string{},
   131  		Endpoint: ghOAuth.Endpoint,
   132  	}
   133  
   134  	return &githubOAuthImpl{ctx: ctx, conf: oauth, ds: store}, nil
   135  }
   136  
   137  func (gaci githubAccessControlImpl) isValidAccessToken() (bool, error) {
   138  	_, res, err := gaci.oauthGHClient.Authorizations.Check(gaci.ctx, gaci.oauthClientID, gaci.token)
   139  	if err != nil {
   140  		return false, err
   141  	}
   142  
   143  	return res.StatusCode == http.StatusOK, nil
   144  }
   145  
   146  func (gaci githubAccessControlImpl) IsValidWPTMember() (bool, error) {
   147  	valid, err := gaci.isValidAccessToken()
   148  	if err != nil {
   149  		return false, err
   150  	}
   151  	if !valid {
   152  		return false, errors.New("Invalid access token")
   153  	}
   154  	isMember, _, err := gaci.botClient.Organizations.IsMember(gaci.ctx, "web-platform-tests", gaci.user.GitHubHandle)
   155  	return isMember, err
   156  }
   157  
   158  func (gaci githubAccessControlImpl) IsValidAdmin() (bool, error) {
   159  	valid, err := gaci.isValidAccessToken()
   160  	if err != nil {
   161  		return false, err
   162  	}
   163  	if !valid {
   164  		return false, errors.New("Invalid access token")
   165  	}
   166  	key := gaci.ds.NewNameKey("Admin", gaci.user.GitHubHandle)
   167  	var dst struct{}
   168  	if err := gaci.ds.Get(key, &dst); err == ErrNoSuchEntity {
   169  		return false, nil
   170  	} else if err != nil {
   171  		return false, err
   172  	}
   173  	return true, nil
   174  }
   175  
   176  // NewGitHubAccessControl returns a GitHubAccessControl for checking the
   177  // permission of a logged-in GitHub user.
   178  func NewGitHubAccessControl(ctx context.Context, ds Datastore, botClient *github.Client, user *User, token string) (GitHubAccessControl, error) {
   179  	clientID, secret, err := getOAuthClientIDSecret(ds)
   180  	if err != nil {
   181  		return nil, err
   182  	}
   183  	tp := github.BasicAuthTransport{
   184  		Username: clientID,
   185  		Password: secret,
   186  	}
   187  	return githubAccessControlImpl{
   188  		ctx:           ctx,
   189  		ds:            ds,
   190  		user:          user,
   191  		token:         token,
   192  		oauthClientID: clientID,
   193  		oauthGHClient: github.NewClient(tp.Client()),
   194  		botClient:     botClient,
   195  	}, nil
   196  }
   197  
   198  // NewGitHubAccessControlFromRequest returns a GitHubAccessControl for checking
   199  // the permission of a logged-in GitHub user from a request. (nil, nil) will be
   200  // returned if the user is not logged in.
   201  func NewGitHubAccessControlFromRequest(aeAPI AppEngineAPI, ds Datastore, r *http.Request) (GitHubAccessControl, error) {
   202  	ctx := aeAPI.Context()
   203  	botClient, err := aeAPI.GetGitHubClient()
   204  	if err != nil {
   205  		return nil, err
   206  	}
   207  	user, token := GetUserFromCookie(ctx, ds, r)
   208  	if user == nil {
   209  		return nil, nil
   210  	}
   211  	return NewGitHubAccessControl(ctx, ds, botClient, user, token)
   212  }
   213  
   214  // NewSecureCookie returns a SecureCookie instance for wpt.fyi. This instance
   215  // can be used to encode and decode cookies set by wpt.fyi.
   216  func NewSecureCookie(store Datastore) (*securecookie.SecureCookie, error) {
   217  	hashKey, err := GetSecret(store, "secure-cookie-hashkey")
   218  	if err != nil {
   219  		return nil, fmt.Errorf("failed to get secure-cookie-hashkey secret: %v", err)
   220  	}
   221  
   222  	blockKey, err := GetSecret(store, "secure-cookie-blockkey")
   223  	if err != nil {
   224  		return nil, fmt.Errorf("failed to get secure-cookie-blockkey secret: %v", err)
   225  	}
   226  
   227  	secureCookie := securecookie.New([]byte(hashKey), []byte(blockKey))
   228  	return secureCookie, nil
   229  }
   230  
   231  // GetUserFromCookie extracts the User and GitHub OAuth token from a request's
   232  // session cookie, if it exists. If the cookie does not exist or cannot be
   233  // decoded, (nil, "") will be returned.
   234  func GetUserFromCookie(ctx context.Context, ds Datastore, r *http.Request) (*User, string) {
   235  	log := GetLogger(ctx)
   236  	if cookie, err := r.Cookie("session"); err == nil && cookie != nil {
   237  		cookieValue := make(map[string]interface{})
   238  		sc, err := NewSecureCookie(ds)
   239  		if err != nil {
   240  			log.Errorf("Failed to create SecureCookie: %s", err.Error())
   241  			return nil, ""
   242  		}
   243  
   244  		if err = sc.Decode("session", cookie.Value, &cookieValue); err == nil {
   245  			decodedUser, okUser := cookieValue["user"].(User)
   246  			decodedToken, okToken := cookieValue["token"].(string)
   247  			if okUser && okToken {
   248  				return &decodedUser, decodedToken
   249  			}
   250  			log.Errorf("Failed to cast user or token")
   251  		} else {
   252  			log.Errorf("Failed to decode cookie: %s", err.Error())
   253  		}
   254  	}
   255  	return nil, ""
   256  }
   257  
   258  // NewGitHubClientFromToken returns a new GitHub client from an access token.
   259  func NewGitHubClientFromToken(ctx context.Context, token string) *github.Client {
   260  	oauthClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{
   261  		AccessToken: token,
   262  	}))
   263  	return github.NewClient(oauthClient)
   264  }
   265  
   266  func getOAuthClientIDSecret(store Datastore) (clientID, clientSecret string, err error) {
   267  	clientID, err = GetSecret(store, "github-oauth-client-id")
   268  	if err != nil {
   269  		return "", "", err
   270  	}
   271  	clientSecret, err = GetSecret(store, "github-oauth-client-secret")
   272  	if err != nil {
   273  		return "", "", err
   274  	}
   275  	return clientID, clientSecret, nil
   276  }