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