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 }