1package providers 2 3import ( 4 "context" 5 "errors" 6 "fmt" 7 "net/http" 8 "net/url" 9 "path" 10 "regexp" 11 "strconv" 12 "strings" 13 14 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" 15 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" 16 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" 17) 18 19// GitHubProvider represents an GitHub based Identity Provider 20type GitHubProvider struct { 21 *ProviderData 22 Org string 23 Team string 24 Repo string 25 Token string 26 Users []string 27} 28 29var _ Provider = (*GitHubProvider)(nil) 30 31const ( 32 githubProviderName = "GitHub" 33 githubDefaultScope = "user:email" 34) 35 36var ( 37 // Default Login URL for GitHub. 38 // Pre-parsed URL of https://github.org/login/oauth/authorize. 39 githubDefaultLoginURL = &url.URL{ 40 Scheme: "https", 41 Host: "github.com", 42 Path: "/login/oauth/authorize", 43 } 44 45 // Default Redeem URL for GitHub. 46 // Pre-parsed URL of https://github.org/login/oauth/access_token. 47 githubDefaultRedeemURL = &url.URL{ 48 Scheme: "https", 49 Host: "github.com", 50 Path: "/login/oauth/access_token", 51 } 52 53 // Default Validation URL for GitHub. 54 // ValidationURL is the API Base URL. 55 // Other API requests are based off of this (eg to fetch users/groups). 56 // Pre-parsed URL of https://api.github.com/. 57 githubDefaultValidateURL = &url.URL{ 58 Scheme: "https", 59 Host: "api.github.com", 60 Path: "/", 61 } 62) 63 64// NewGitHubProvider initiates a new GitHubProvider 65func NewGitHubProvider(p *ProviderData) *GitHubProvider { 66 p.setProviderDefaults(providerDefaults{ 67 name: githubProviderName, 68 loginURL: githubDefaultLoginURL, 69 redeemURL: githubDefaultRedeemURL, 70 profileURL: nil, 71 validateURL: githubDefaultValidateURL, 72 scope: githubDefaultScope, 73 }) 74 return &GitHubProvider{ProviderData: p} 75} 76 77func makeGitHubHeader(accessToken string) http.Header { 78 // extra headers required by the GitHub API when making authenticated requests 79 extraHeaders := map[string]string{ 80 acceptHeader: "application/vnd.github.v3+json", 81 } 82 return makeAuthorizationHeader(tokenTypeToken, accessToken, extraHeaders) 83} 84 85// SetOrgTeam adds GitHub org reading parameters to the OAuth2 scope 86func (p *GitHubProvider) SetOrgTeam(org, team string) { 87 p.Org = org 88 p.Team = team 89 if org != "" || team != "" { 90 p.Scope += " read:org" 91 } 92} 93 94// SetRepo configures the target repository and optional token to use 95func (p *GitHubProvider) SetRepo(repo, token string) { 96 p.Repo = repo 97 p.Token = token 98} 99 100// SetUsers configures allowed usernames 101func (p *GitHubProvider) SetUsers(users []string) { 102 p.Users = users 103} 104 105// EnrichSession updates the User & Email after the initial Redeem 106func (p *GitHubProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { 107 err := p.getEmail(ctx, s) 108 if err != nil { 109 return err 110 } 111 return p.getUser(ctx, s) 112} 113 114// ValidateSession validates the AccessToken 115func (p *GitHubProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool { 116 return validateToken(ctx, p, s.AccessToken, makeGitHubHeader(s.AccessToken)) 117} 118 119func (p *GitHubProvider) hasOrg(ctx context.Context, accessToken string) (bool, error) { 120 // https://developer.github.com/v3/orgs/#list-your-organizations 121 122 var orgs []struct { 123 Login string `json:"login"` 124 } 125 126 type orgsPage []struct { 127 Login string `json:"login"` 128 } 129 130 pn := 1 131 for { 132 params := url.Values{ 133 "per_page": {"100"}, 134 "page": {strconv.Itoa(pn)}, 135 } 136 137 endpoint := &url.URL{ 138 Scheme: p.ValidateURL.Scheme, 139 Host: p.ValidateURL.Host, 140 Path: path.Join(p.ValidateURL.Path, "/user/orgs"), 141 RawQuery: params.Encode(), 142 } 143 144 var op orgsPage 145 err := requests.New(endpoint.String()). 146 WithContext(ctx). 147 WithHeaders(makeGitHubHeader(accessToken)). 148 Do(). 149 UnmarshalInto(&op) 150 if err != nil { 151 return false, err 152 } 153 154 if len(op) == 0 { 155 break 156 } 157 158 orgs = append(orgs, op...) 159 pn++ 160 } 161 162 presentOrgs := make([]string, 0, len(orgs)) 163 for _, org := range orgs { 164 if p.Org == org.Login { 165 logger.Printf("Found Github Organization: %q", org.Login) 166 return true, nil 167 } 168 presentOrgs = append(presentOrgs, org.Login) 169 } 170 171 logger.Printf("Missing Organization:%q in %v", p.Org, presentOrgs) 172 return false, nil 173} 174 175func (p *GitHubProvider) hasOrgAndTeam(ctx context.Context, accessToken string) (bool, error) { 176 // https://developer.github.com/v3/orgs/teams/#list-user-teams 177 178 var teams []struct { 179 Name string `json:"name"` 180 Slug string `json:"slug"` 181 Org struct { 182 Login string `json:"login"` 183 } `json:"organization"` 184 } 185 186 type teamsPage []struct { 187 Name string `json:"name"` 188 Slug string `json:"slug"` 189 Org struct { 190 Login string `json:"login"` 191 } `json:"organization"` 192 } 193 194 pn := 1 195 last := 0 196 for { 197 params := url.Values{ 198 "per_page": {"100"}, 199 "page": {strconv.Itoa(pn)}, 200 } 201 202 endpoint := &url.URL{ 203 Scheme: p.ValidateURL.Scheme, 204 Host: p.ValidateURL.Host, 205 Path: path.Join(p.ValidateURL.Path, "/user/teams"), 206 RawQuery: params.Encode(), 207 } 208 209 // bodyclose cannot detect that the body is being closed later in requests.Into, 210 // so have to skip the linting for the next line. 211 // nolint:bodyclose 212 result := requests.New(endpoint.String()). 213 WithContext(ctx). 214 WithHeaders(makeGitHubHeader(accessToken)). 215 Do() 216 if result.Error() != nil { 217 return false, result.Error() 218 } 219 220 if last == 0 { 221 // link header may not be obtained 222 // When paging is not required and all data can be retrieved with a single call 223 224 // Conditions for obtaining the link header. 225 // 1. When paging is required (Example: When the data size is 100 and the page size is 99 or less) 226 // 2. When it exceeds the paging frame (Example: When there is only 10 records but the second page is called with a page size of 100) 227 228 // link header at not last page 229 // <https://api.github.com/user/teams?page=1&per_page=100>; rel="prev", <https://api.github.com/user/teams?page=1&per_page=100>; rel="last", <https://api.github.com/user/teams?page=1&per_page=100>; rel="first" 230 // link header at last page (doesn't exist last info) 231 // <https://api.github.com/user/teams?page=3&per_page=10>; rel="prev", <https://api.github.com/user/teams?page=1&per_page=10>; rel="first" 232 233 link := result.Headers().Get("Link") 234 rep1 := regexp.MustCompile(`(?s).*\<https://api.github.com/user/teams\?page=(.)&per_page=[0-9]+\>; rel="last".*`) 235 i, converr := strconv.Atoi(rep1.ReplaceAllString(link, "$1")) 236 237 // If the last page cannot be taken from the link in the http header, the last variable remains zero 238 if converr == nil { 239 last = i 240 } 241 } 242 243 var tp teamsPage 244 if err := result.UnmarshalInto(&tp); err != nil { 245 return false, err 246 } 247 if len(tp) == 0 { 248 break 249 } 250 251 teams = append(teams, tp...) 252 253 if pn == last { 254 break 255 } 256 if last == 0 { 257 break 258 } 259 260 pn++ 261 } 262 263 var hasOrg bool 264 presentOrgs := make(map[string]bool) 265 var presentTeams []string 266 for _, team := range teams { 267 presentOrgs[team.Org.Login] = true 268 if p.Org == team.Org.Login { 269 hasOrg = true 270 ts := strings.Split(p.Team, ",") 271 for _, t := range ts { 272 if t == team.Slug { 273 logger.Printf("Found Github Organization:%q Team:%q (Name:%q)", team.Org.Login, team.Slug, team.Name) 274 return true, nil 275 } 276 } 277 presentTeams = append(presentTeams, team.Slug) 278 } 279 } 280 if hasOrg { 281 logger.Printf("Missing Team:%q from Org:%q in teams: %v", p.Team, p.Org, presentTeams) 282 } else { 283 var allOrgs []string 284 for org := range presentOrgs { 285 allOrgs = append(allOrgs, org) 286 } 287 logger.Printf("Missing Organization:%q in %#v", p.Org, allOrgs) 288 } 289 return false, nil 290} 291 292func (p *GitHubProvider) hasRepo(ctx context.Context, accessToken string) (bool, error) { 293 // https://developer.github.com/v3/repos/#get-a-repository 294 295 type permissions struct { 296 Pull bool `json:"pull"` 297 Push bool `json:"push"` 298 } 299 300 type repository struct { 301 Permissions permissions `json:"permissions"` 302 Private bool `json:"private"` 303 } 304 305 endpoint := &url.URL{ 306 Scheme: p.ValidateURL.Scheme, 307 Host: p.ValidateURL.Host, 308 Path: path.Join(p.ValidateURL.Path, "/repo/", p.Repo), 309 } 310 311 var repo repository 312 err := requests.New(endpoint.String()). 313 WithContext(ctx). 314 WithHeaders(makeGitHubHeader(accessToken)). 315 Do(). 316 UnmarshalInto(&repo) 317 if err != nil { 318 return false, err 319 } 320 321 // Every user can implicitly pull from a public repo, so only grant access 322 // if they have push access or the repo is private and they can pull 323 return repo.Permissions.Push || (repo.Private && repo.Permissions.Pull), nil 324} 325 326func (p *GitHubProvider) hasUser(ctx context.Context, accessToken string) (bool, error) { 327 // https://developer.github.com/v3/users/#get-the-authenticated-user 328 329 var user struct { 330 Login string `json:"login"` 331 Email string `json:"email"` 332 } 333 334 endpoint := &url.URL{ 335 Scheme: p.ValidateURL.Scheme, 336 Host: p.ValidateURL.Host, 337 Path: path.Join(p.ValidateURL.Path, "/user"), 338 } 339 340 err := requests.New(endpoint.String()). 341 WithContext(ctx). 342 WithHeaders(makeGitHubHeader(accessToken)). 343 Do(). 344 UnmarshalInto(&user) 345 if err != nil { 346 return false, err 347 } 348 349 if p.isVerifiedUser(user.Login) { 350 return true, nil 351 } 352 return false, nil 353} 354 355func (p *GitHubProvider) isCollaborator(ctx context.Context, username, accessToken string) (bool, error) { 356 //https://developer.github.com/v3/repos/collaborators/#check-if-a-user-is-a-collaborator 357 358 endpoint := &url.URL{ 359 Scheme: p.ValidateURL.Scheme, 360 Host: p.ValidateURL.Host, 361 Path: path.Join(p.ValidateURL.Path, "/repos/", p.Repo, "/collaborators/", username), 362 } 363 result := requests.New(endpoint.String()). 364 WithContext(ctx). 365 WithHeaders(makeGitHubHeader(accessToken)). 366 Do() 367 if result.Error() != nil { 368 return false, result.Error() 369 } 370 371 if result.StatusCode() != 204 { 372 return false, fmt.Errorf("got %d from %q %s", 373 result.StatusCode(), endpoint.String(), result.Body()) 374 } 375 376 logger.Printf("got %d from %q %s", result.StatusCode(), endpoint.String(), result.Body()) 377 378 return true, nil 379} 380 381// getEmail updates the SessionState Email 382func (p *GitHubProvider) getEmail(ctx context.Context, s *sessions.SessionState) error { 383 384 var emails []struct { 385 Email string `json:"email"` 386 Primary bool `json:"primary"` 387 Verified bool `json:"verified"` 388 } 389 390 // If usernames are set, check that first 391 verifiedUser := false 392 if len(p.Users) > 0 { 393 var err error 394 verifiedUser, err = p.hasUser(ctx, s.AccessToken) 395 if err != nil { 396 return err 397 } 398 // org and repository options are not configured 399 if !verifiedUser && p.Org == "" && p.Repo == "" { 400 return errors.New("missing github user") 401 } 402 } 403 // If a user is verified by username options, skip the following restrictions 404 if !verifiedUser { 405 if p.Org != "" { 406 if p.Team != "" { 407 if ok, err := p.hasOrgAndTeam(ctx, s.AccessToken); err != nil || !ok { 408 return err 409 } 410 } else { 411 if ok, err := p.hasOrg(ctx, s.AccessToken); err != nil || !ok { 412 return err 413 } 414 } 415 } else if p.Repo != "" && p.Token == "" { // If we have a token we'll do the collaborator check in GetUserName 416 if ok, err := p.hasRepo(ctx, s.AccessToken); err != nil || !ok { 417 return err 418 } 419 } 420 } 421 422 endpoint := &url.URL{ 423 Scheme: p.ValidateURL.Scheme, 424 Host: p.ValidateURL.Host, 425 Path: path.Join(p.ValidateURL.Path, "/user/emails"), 426 } 427 err := requests.New(endpoint.String()). 428 WithContext(ctx). 429 WithHeaders(makeGitHubHeader(s.AccessToken)). 430 Do(). 431 UnmarshalInto(&emails) 432 if err != nil { 433 return err 434 } 435 436 for _, email := range emails { 437 if email.Verified { 438 if email.Primary { 439 s.Email = email.Email 440 return nil 441 } 442 } 443 } 444 445 return nil 446} 447 448// getUser updates the SessionState User 449func (p *GitHubProvider) getUser(ctx context.Context, s *sessions.SessionState) error { 450 var user struct { 451 Login string `json:"login"` 452 Email string `json:"email"` 453 } 454 455 endpoint := &url.URL{ 456 Scheme: p.ValidateURL.Scheme, 457 Host: p.ValidateURL.Host, 458 Path: path.Join(p.ValidateURL.Path, "/user"), 459 } 460 461 err := requests.New(endpoint.String()). 462 WithContext(ctx). 463 WithHeaders(makeGitHubHeader(s.AccessToken)). 464 Do(). 465 UnmarshalInto(&user) 466 if err != nil { 467 return err 468 } 469 470 // Now that we have the username we can check collaborator status 471 if !p.isVerifiedUser(user.Login) && p.Org == "" && p.Repo != "" && p.Token != "" { 472 if ok, err := p.isCollaborator(ctx, user.Login, p.Token); err != nil || !ok { 473 return err 474 } 475 } 476 477 s.User = user.Login 478 return nil 479} 480 481// isVerifiedUser 482func (p *GitHubProvider) isVerifiedUser(username string) bool { 483 for _, u := range p.Users { 484 if username == u { 485 return true 486 } 487 } 488 return false 489} 490