@@ -15,6 +15,7 @@ import (
1515 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
1616 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
1717 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
18+ "golang.org/x/exp/maps"
1819)
1920
2021// GitHubProvider represents an GitHub based Identity Provider
@@ -31,7 +32,8 @@ var _ Provider = (*GitHubProvider)(nil)
3132
3233const (
3334 githubProviderName = "GitHub"
34− githubDefaultScope = "user:email"
35+ githubDefaultScope = "user:email read:org"
36+ orgTeamSeparator = ":"
3537)
3638
3739var (
@@ -114,9 +116,6 @@ func (p *GitHubProvider) makeGitHubAPIEndpoint(endpoint string, params *url.Valu
114116func (p *GitHubProvider) setOrgTeam(org, team string) {
115117 p.Org = org
116118 p.Team = team
117− if org != "" || team != "" {
118− p.Scope += " read:org"
119− }
120119}
121120
122121// setRepo configures the target repository and optional token to use
@@ -132,10 +131,19 @@ func (p *GitHubProvider) setUsers(users []string) {
132131
133132// EnrichSession updates the User & Email after the initial Redeem
134133func (p *GitHubProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
135− err := p.getEmail(ctx, s)
136− if err != nil {
134+ // Construct user info JSON from multiple GitHub API endpoints to have a more detailed session state
135+ if err := p.getOrgAndTeam(ctx, s); err != nil {
137136 return err
138137 }
138+
139+ if err := p.checkRestrictions(ctx, s); err != nil {
140+ return err
141+ }
142+
143+ if err := p.getEmail(ctx, s); err != nil {
144+ return err
145+ }
146+
139147 return p.getUser(ctx, s)
140148}
141149
@@ -144,170 +152,75 @@ func (p *GitHubProvider) ValidateSession(ctx context.Context, s *sessions.Sessio
144152 return validateToken(ctx, p, s.AccessToken, makeGitHubHeader(s.AccessToken))
145153}
146154
147−func (p *GitHubProvider) hasOrg(ctx context.Context, accessToken string) (bool, error) {
155+func (p *GitHubProvider) hasOrg(ctx context.Context, s *sessions.SessionState) error {
148156 // https://developer.github.com/v3/orgs/#list-your-organizations
157+ var orgs []string
149158
150− var orgs []struct {
151− Login string `json:"login"`
152− }
153−
154− type orgsPage []struct {
155− Login string `json:"login"`
156− }
157−
158− pn := 1
159− for {
160− params := url.Values{
161− "per_page": {"100"},
162− "page": {strconv.Itoa(pn)},
163− }
164−
165− endpoint := p.makeGitHubAPIEndpoint("/user/orgs", ¶ms)
166−
167− var op orgsPage
168− err := requests.New(endpoint.String()).
169− WithContext(ctx).
170− WithHeaders(makeGitHubHeader(accessToken)).
171− Do().
172− UnmarshalInto(&op)
173− if err != nil {
174− return false, err
175− }
176−
177− if len(op) == 0 {
178− break
159+ for _, group := range s.Groups {
160+ if !strings.Contains(group, ":") {
161+ orgs = append(orgs, group)
179162 }
180−
181− orgs = append(orgs, op...)
182− pn++
183163 }
184164
185165 presentOrgs := make([]string, 0, len(orgs))
186166 for _, org := range orgs {
187− if p.Org == org.Login {
188− logger.Printf("Found Github Organization: %q", org.Login)
189− return true, nil
167+ if p.Org == org {
168+ logger.Printf("Found Github Organization:%q", org)
169+ return nil
190170 }
191− presentOrgs = append(presentOrgs, org.Login)
171+ presentOrgs = append(presentOrgs, org)
192172 }
193173
194174 logger.Printf("Missing Organization:%q in %v", p.Org, presentOrgs)
195− return false, nil
175+ return errors.New("user is missing required organization")
196176}
197177
198−func (p *GitHubProvider) hasOrgAndTeam(ctx context.Context, accessToken string) (bool, error) {
199− // https://developer.github.com/v3/orgs/teams/#list-user-teams
200−
201− var teams []struct {
202− Name string `json:"name"`
203− Slug string `json:"slug"`
204− Org struct {
205− Login string `json:"login"`
206− } `json:"organization"`
207− }
208−
209− type teamsPage []struct {
210− Name string `json:"name"`
211− Slug string `json:"slug"`
212− Org struct {
213− Login string `json:"login"`
214− } `json:"organization"`
178+func (p *GitHubProvider) hasOrgAndTeam(ctx context.Context, s *sessions.SessionState) error {
179+ type orgTeam struct {
180+ Org string `json:"org"`
181+ Team string `json:"team"`
215182 }
216183
217− pn := 1
218− last := 0
219− for {
220− params := url.Values{
221− "per_page": {"100"},
222− "page": {strconv.Itoa(pn)},
223− }
224−
225− endpoint := p.makeGitHubAPIEndpoint("/user/teams", ¶ms)
184+ var presentOrgTeams []orgTeam
226185
227− // bodyclose cannot detect that the body is being closed later in requests.Into,
228− // so have to skip the linting for the next line.
229− // nolint:bodyclose
230− result := requests.New(endpoint.String()).
231− WithContext(ctx).
232− WithHeaders(makeGitHubHeader(accessToken)).
233− Do()
234− if result.Error() != nil {
235− return false, result.Error()
236− }
237−
238− if last == 0 {
239− // link header may not be obtained
240− // When paging is not required and all data can be retrieved with a single call
241−
242− // Conditions for obtaining the link header.
243− // 1. When paging is required (Example: When the data size is 100 and the page size is 99 or less)
244− // 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)
245−
246− // link header at not last page
247− // <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"
248− // link header at last page (doesn't exist last info)
249− // <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"
250−
251− link := result.Headers().Get("Link")
252− rep1 := regexp.MustCompile(`(?s).*\<https://api.github.com/user/teams\?page=(.)&per_page=[0-9]+\>; rel="last".*`)
253− i, converr := strconv.Atoi(rep1.ReplaceAllString(link, "$1"))
254−
255− // If the last page cannot be taken from the link in the http header, the last variable remains zero
256− if converr == nil {
257− last = i
258− }
186+ for _, group := range s.Groups {
187+ if strings.Contains(group, orgTeamSeparator) {
188+ ot := strings.Split(group, orgTeamSeparator)
189+ presentOrgTeams = append(presentOrgTeams, orgTeam{ot[0], ot[1]})
259190 }
260−
261− var tp teamsPage
262− if err := result.UnmarshalInto(&tp); err != nil {
263− return false, err
264− }
265− if len(tp) == 0 {
266− break
267− }
268−
269− teams = append(teams, tp...)
270−
271− if pn == last {
272− break
273− }
274− if last == 0 {
275− break
276− }
277−
278− pn++
279191 }
280192
281193 var hasOrg bool
194+
282195 presentOrgs := make(map[string]bool)
283196 var presentTeams []string
284− for _, team := range teams {
285− presentOrgs[team.Org.Login] = true
286− if p.Org == team.Org.Login {
197+
198+ for _, ot := range presentOrgTeams {
199+ presentOrgs[ot.Org] = true
200+
201+ if strings.EqualFold(p.Org, ot.Org) {
287202 hasOrg = true
288− ts := strings.Split(p.Team, ",")
289− for _, t := range ts {
290− if t == team.Slug {
291− logger.Printf("Found Github Organization:%q Team:%q (Name:%q)", team.Org.Login, team.Slug, team.Name)
292− return true, nil
203+ teams := strings.Split(p.Team, ",")
204+ for _, team := range teams {
205+ if strings.EqualFold(strings.TrimSpace(team), ot.Team) {
206+ logger.Printf("Found Github Organization/Team:%q/%q", ot.Org, ot.Team)
207+ return nil
293208 }
294209 }
295− presentTeams = append(presentTeams, team.Slug)
210+ presentTeams = append(presentTeams, ot.Team)
296211 }
297212 }
213+
298214 if hasOrg {
299215 logger.Printf("Missing Team:%q from Org:%q in teams: %v", p.Team, p.Org, presentTeams)
300− } else {
301− var allOrgs []string
302− for org := range presentOrgs {
303− allOrgs = append(allOrgs, org)
304− }
305− logger.Printf("Missing Organization:%q in %#v", p.Org, allOrgs)
216+ return errors.New("user is missing required team")
306217 }
307− return false, nil
218+
219+ logger.Printf("Missing Organization:%q in %#v", p.Org, maps.Keys(presentOrgs))
220+ return errors.New("user is missing required organization")
308221}
309222
310−func (p *GitHubProvider) hasRepo(ctx context.Context, accessToken string) (bool, error) {
223+func (p *GitHubProvider) hasRepoAccess(ctx context.Context, accessToken string) error {
311224 // https://developer.github.com/v3/repos/#get-a-repository
312225
313226 type permissions struct {
@@ -328,13 +241,18 @@ func (p *GitHubProvider) hasRepo(ctx context.Context, accessToken string) (bool,
328241 WithHeaders(makeGitHubHeader(accessToken)).
329242 Do().
330243 UnmarshalInto(&repo)
244+
331245 if err != nil {
332− return false, err
246+ return err
333247 }
334248
335249 // Every user can implicitly pull from a public repo, so only grant access
336250 // if they have push access or the repo is private and they can pull
337− return repo.Permissions.Push || (repo.Private && repo.Permissions.Pull), nil
251+ if repo.Permissions.Push || (repo.Private && repo.Permissions.Pull) {
252+ return nil
253+ }
254+
255+ return errors.New("user doesn't have repository access")
338256}
339257
340258func (p *GitHubProvider) hasUser(ctx context.Context, accessToken string) (bool, error) {
@@ -393,39 +311,8 @@ func (p *GitHubProvider) getEmail(ctx context.Context, s *sessions.SessionState)
393311 Verified bool `json:"verified"`
394312 }
395313
396− // If usernames are set, check that first
397− verifiedUser := false
398− if len(p.Users) > 0 {
399− var err error
400− verifiedUser, err = p.hasUser(ctx, s.AccessToken)
401− if err != nil {
402− return err
403− }
404− // org and repository options are not configured
405− if !verifiedUser && p.Org == "" && p.Repo == "" {
406− return errors.New("missing github user")
407− }
408− }
409− // If a user is verified by username options, skip the following restrictions
410− if !verifiedUser {
411− if p.Org != "" {
412− if p.Team != "" {
413− if ok, err := p.hasOrgAndTeam(ctx, s.AccessToken); err != nil || !ok {
414− return err
415− }
416− } else {
417− if ok, err := p.hasOrg(ctx, s.AccessToken); err != nil || !ok {
418− return err
419− }
420− }
421− } else if p.Repo != "" && p.Token == "" { // If we have a token we'll do the collaborator check in GetUserName
422− if ok, err := p.hasRepo(ctx, s.AccessToken); err != nil || !ok {
423− return err
424− }
425− }
426− }
427−
428314 endpoint := p.makeGitHubAPIEndpoint("/user/emails", nil)
315+
429316 err := requests.New(endpoint.String()).
430317 WithContext(ctx).
431318 WithHeaders(makeGitHubHeader(s.AccessToken)).
@@ -476,7 +363,6 @@ func (p *GitHubProvider) getUser(ctx context.Context, s *sessions.SessionState)
476363 return nil
477364}
478365
479−// isVerifiedUser
480366func (p *GitHubProvider) isVerifiedUser(username string) bool {
481367 for _, u := range p.Users {
482368 if username == u {
@@ -485,3 +371,144 @@ func (p *GitHubProvider) isVerifiedUser(username string) bool {
485371 }
486372 return false
487373}
374+
375+func (p *GitHubProvider) checkRestrictions(ctx context.Context, s *sessions.SessionState) error {
376+ // If a user is verified by username options, skip the following restrictions
377+ if ok, err := p.checkUserRestriction(ctx, s); err != nil || ok {
378+ return err
379+ }
380+
381+ if err := p.hasOrgAndTeamAccess(ctx, s); err != nil {
382+ return err
383+ }
384+
385+ if p.Org == "" && p.Repo != "" && p.Token == "" {
386+ // If we have a token we'll do the collaborator check in GetUserName
387+ return p.hasRepoAccess(ctx, s.AccessToken)
388+ }
389+
390+ return nil
391+}
392+
393+func (p *GitHubProvider) checkUserRestriction(ctx context.Context, s *sessions.SessionState) (bool, error) {
394+ if len(p.Users) == 0 {
395+ return false, nil
396+ }
397+
398+ verifiedUser, err := p.hasUser(ctx, s.AccessToken)
399+ if err != nil {
400+ return verifiedUser, err
401+ }
402+
403+ // org and repository options are not configured
404+ if !verifiedUser && p.Org == "" && p.Repo == "" {
405+ return false, errors.New("missing github user")
406+ }
407+
408+ return verifiedUser, nil
409+}
410+
411+func (p *GitHubProvider) hasOrgAndTeamAccess(ctx context.Context, s *sessions.SessionState) error {
412+ if p.Org != "" && p.Team != "" {
413+ return p.hasOrgAndTeam(ctx, s)
414+ }
415+
416+ if p.Org != "" {
417+ return p.hasOrg(ctx, s)
418+ }
419+
420+ return nil
421+}
422+
423+func (p *GitHubProvider) getOrgAndTeam(ctx context.Context, s *sessions.SessionState) error {
424+ err := p.getOrgs(ctx, s)
425+ if err != nil {
426+ return err
427+ }
428+
429+ return p.getTeams(ctx, s)
430+}
431+
432+func (p *GitHubProvider) getOrgs(ctx context.Context, s *sessions.SessionState) error {
433+ // https://docs.github.com/en/rest/orgs/orgs#list-organizations-for-the-authenticated-user
434+
435+ type Organization struct {
436+ Login string `json:"login"`
437+ }
438+
439+ pn := 1
440+ for {
441+ params := url.Values{
442+ "per_page": {"100"},
443+ "page": {strconv.Itoa(pn)},
444+ }
445+
446+ endpoint := p.makeGitHubAPIEndpoint("/user/orgs", ¶ms)
447+
448+ var orgs []Organization
449+ err := requests.New(endpoint.String()).
450+ WithContext(ctx).
451+ WithHeaders(makeGitHubHeader(s.AccessToken)).
452+ Do().
453+ UnmarshalInto(&orgs)
454+ if err != nil {
455+ return err
456+ }
457+
458+ if len(orgs) == 0 {
459+ break
460+ }
461+
462+ for _, org := range orgs {
463+ logger.Printf("Member of Github Organization:%q", org.Login)
464+ s.Groups = append(s.Groups, org.Login)
465+ }
466+ pn++
467+ }
468+
469+ return nil
470+}
471+
472+func (p *GitHubProvider) getTeams(ctx context.Context, s *sessions.SessionState) error {
473+ // https://docs.github.com/en/rest/teams/teams?#list-user-teams
474+ type Team struct {
475+ Name string `json:"name"`
476+ Slug string `json:"slug"`
477+ Org struct {
478+ Login string `json:"login"`
479+ } `json:"organization"`
480+ }
481+
482+ pn := 1
483+ for {
484+ params := url.Values{
485+ "per_page": {"100"},
486+ "page": {strconv.Itoa(pn)},
487+ }
488+
489+ endpoint := p.makeGitHubAPIEndpoint("/user/teams", ¶ms)
490+
491+ var teams []Team
492+ err := requests.New(endpoint.String()).
493+ WithContext(ctx).
494+ WithHeaders(makeGitHubHeader(s.AccessToken)).
495+ Do().
496+ UnmarshalInto(&teams)
497+ if err != nil {
498+ return err
499+ }
500+
501+ if len(teams) == 0 {
502+ break
503+ }
504+
505+ for _, team := range teams {
506+ logger.Printf("Member of Github Organization/Team:%q/%q", team.Org.Login, team.Slug)
507+ s.Groups = append(s.Groups, team.Org.Login+orgTeamSeparator+team.Slug)
508+ }
509+
510+ pn++
511+ }
512+
513+ return nil
514+}