git.iko.soy/typeable/oauth2-proxy
on master help sign in
568 lines 14 kB history raw
1package providers
3import (
4 "context"
5 "errors"
6 "fmt"
7 "maps"
8 "net/http"
9 "net/url"
10 "path"
11 "regexp"
12 "strconv"
13 "strings"
15 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
16 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
17 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
18 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
21// GitHubProvider represents an GitHub based Identity Provider
22type GitHubProvider struct {
23 *ProviderData
24 Org string
25 Team string
26 Repo string
27 Token string
28 Users []string
31var _ Provider = (*GitHubProvider)(nil)
33const (
34 githubProviderName = "GitHub"
35 githubDefaultScope = "user:email read:org"
36 orgTeamSeparator = ":"
39var (
40 // Default Login URL for GitHub.
41 // Pre-parsed URL of https://github.org/login/oauth/authorize.
42 githubDefaultLoginURL = &url.URL{
43 Scheme: "https",
44 Host: "github.com",
45 Path: "/login/oauth/authorize",
46 }
48 // Default Redeem URL for GitHub.
49 // Pre-parsed URL of https://github.org/login/oauth/access_token.
50 githubDefaultRedeemURL = &url.URL{
51 Scheme: "https",
52 Host: "github.com",
53 Path: "/login/oauth/access_token",
54 }
56 // Default Validation URL for GitHub.
57 // ValidationURL is the API Base URL.
58 // Other API requests are based off of this (eg to fetch users/groups).
59 // Pre-parsed URL of https://api.github.com/.
60 githubDefaultValidateURL = &url.URL{
61 Scheme: "https",
62 Host: "api.github.com",
63 Path: "/",
64 }
67// NewGitHubProvider initiates a new GitHubProvider
68func NewGitHubProvider(p *ProviderData, opts options.GitHubOptions) *GitHubProvider {
69 p.setProviderDefaults(providerDefaults{
70 name: githubProviderName,
71 loginURL: githubDefaultLoginURL,
72 redeemURL: githubDefaultRedeemURL,
73 profileURL: nil,
74 validateURL: githubDefaultValidateURL,
75 scope: githubDefaultScope,
76 })
78 provider := &GitHubProvider{ProviderData: p}
80 provider.setOrgTeam(opts.Org, opts.Team)
81 provider.setRepo(opts.Repo, opts.Token)
82 provider.setUsers(opts.Users)
83 return provider
86func makeGitHubHeader(accessToken string) http.Header {
87 // extra headers required by the GitHub API when making authenticated requests
88 extraHeaders := map[string]string{
89 acceptHeader: "application/vnd.github.v3+json",
90 }
91 return makeAuthorizationHeader(tokenTypeToken, accessToken, extraHeaders)
94func (p *GitHubProvider) makeGitHubAPIEndpoint(endpoint string, params *url.Values) *url.URL {
95 basePath := p.ValidateURL.Path
97 re := regexp.MustCompile(`^/api/v\d+`)
98 match := re.FindString(p.ValidateURL.Path)
99 if match != "" {
100 basePath = match
101 }
103 if params == nil {
104 params = &url.Values{}
105 }
107 return &url.URL{
108 Scheme: p.ValidateURL.Scheme,
109 Host: p.ValidateURL.Host,
110 Path: path.Join(basePath, endpoint),
111 RawQuery: params.Encode(),
112 }
115// setOrgTeam adds GitHub org reading parameters to the OAuth2 scope
116func (p *GitHubProvider) setOrgTeam(org, team string) {
117 p.Org = org
118 p.Team = team
121// setRepo configures the target repository and optional token to use
122func (p *GitHubProvider) setRepo(repo, token string) {
123 p.Repo = repo
124 p.Token = token
127// setUsers configures allowed usernames
128func (p *GitHubProvider) setUsers(users []string) {
129 p.Users = users
132// EnrichSession updates the User & Email after the initial Redeem
133func (p *GitHubProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error {
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 {
136 return err
137 }
139 if err := p.checkRestrictions(ctx, s); err != nil {
140 return err
141 }
143 if err := p.getEmail(ctx, s); err != nil {
144 return err
145 }
147 return p.getUser(ctx, s)
150// ValidateSession validates the AccessToken
151func (p *GitHubProvider) ValidateSession(ctx context.Context, s *sessions.SessionState) bool {
152 return validateToken(ctx, p, s.AccessToken, makeGitHubHeader(s.AccessToken))
155func (p *GitHubProvider) hasOrg(s *sessions.SessionState) error {
156 // https://developer.github.com/v3/orgs/#list-your-organizations
157 var orgs []string
159 for _, group := range s.Groups {
160 if !strings.Contains(group, ":") {
161 orgs = append(orgs, group)
162 }
163 }
165 presentOrgs := make([]string, 0, len(orgs))
166 for _, org := range orgs {
167 if p.Org == org {
168 logger.Printf("Found Github Organization:%q", org)
169 return nil
170 }
171 presentOrgs = append(presentOrgs, org)
172 }
174 logger.Printf("Missing Organization:%q in %v", p.Org, presentOrgs)
175 return errors.New("user is missing required organization")
178func (p *GitHubProvider) hasOrgAndTeam(s *sessions.SessionState) error {
179 type orgTeam struct {
180 Org string `json:"org"`
181 Team string `json:"team"`
182 }
184 var presentOrgTeams []orgTeam
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]})
190 }
191 }
193 var hasOrg bool
195 presentOrgs := make(map[string]bool)
196 var presentTeams []string
198 for _, ot := range presentOrgTeams {
199 presentOrgs[ot.Org] = true
201 if strings.EqualFold(p.Org, ot.Org) {
202 hasOrg = true
204 teams := strings.Split(p.Team, ",")
205 for _, team := range teams {
206 if strings.EqualFold(strings.TrimSpace(team), ot.Team) {
207 logger.Printf("Found Github Organization/Team:%q/%q", ot.Org, ot.Team)
208 return nil
209 }
210 }
211 presentTeams = append(presentTeams, ot.Team)
212 }
213 }
215 if hasOrg {
216 logger.Printf("Missing Team:%q from Org:%q in teams: %v", p.Team, p.Org, presentTeams)
217 return errors.New("user is missing required team")
218 }
220 logger.Printf("Missing Organization:%q in %#v", p.Org, maps.Keys(presentOrgs))
221 return errors.New("user is missing required organization")
224func (p *GitHubProvider) hasTeam(s *sessions.SessionState) error {
225 var teams []string
227 for _, group := range s.Groups {
228 if strings.Contains(group, orgTeamSeparator) {
229 teams = append(teams, strings.TrimSpace(group))
230 }
231 }
233 var presentTeams = make([]string, 0, len(teams))
235 for _, ot := range teams {
236 allowedTeams := strings.Split(p.Team, ",")
237 for _, team := range allowedTeams {
238 if !strings.Contains(team, orgTeamSeparator) {
239 logger.Printf("Please use fully qualified team names (org:team-slug) if you omit the organisation. Current Team name: %s", team)
240 return errors.New("team name is invalid")
241 }
243 if strings.EqualFold(strings.TrimSpace(team), ot) {
244 logger.Printf("Found Github Organization/Team:%s", ot)
245 return nil
246 }
247 }
248 presentTeams = append(presentTeams, ot)
249 }
251 logger.Printf("Missing Team:%q in teams: %v", p.Team, presentTeams)
252 return errors.New("user is missing required team")
255func (p *GitHubProvider) hasRepoAccess(ctx context.Context, accessToken string) error {
256 // https://developer.github.com/v3/repos/#get-a-repository
258 type permissions struct {
259 Pull bool `json:"pull"`
260 Push bool `json:"push"`
261 }
263 type repository struct {
264 Permissions permissions `json:"permissions"`
265 Private bool `json:"private"`
266 }
268 endpoint := p.makeGitHubAPIEndpoint("/repos/"+p.Repo, nil)
270 var repo repository
271 err := requests.New(endpoint.String()).
272 WithContext(ctx).
273 WithHeaders(makeGitHubHeader(accessToken)).
274 Do().
275 UnmarshalInto(&repo)
277 if err != nil {
278 return err
279 }
281 // Every user can implicitly pull from a public repo, so only grant access
282 // if they have push access or the repo is private and they can pull
283 if repo.Permissions.Push || (repo.Private && repo.Permissions.Pull) {
284 return nil
285 }
287 return errors.New("user doesn't have repository access")
290func (p *GitHubProvider) hasUser(ctx context.Context, accessToken string) (bool, error) {
291 // https://developer.github.com/v3/users/#get-the-authenticated-user
293 var user struct {
294 Login string `json:"login"`
295 Email string `json:"email"`
296 }
298 endpoint := p.makeGitHubAPIEndpoint("/user", nil)
300 err := requests.New(endpoint.String()).
301 WithContext(ctx).
302 WithHeaders(makeGitHubHeader(accessToken)).
303 Do().
304 UnmarshalInto(&user)
305 if err != nil {
306 return false, err
307 }
309 if p.isVerifiedUser(user.Login) {
310 return true, nil
311 }
312 return false, nil
315func (p *GitHubProvider) isCollaborator(ctx context.Context, username, accessToken string) (bool, error) {
316 //https://developer.github.com/v3/repos/collaborators/#check-if-a-user-is-a-collaborator
318 endpoint := p.makeGitHubAPIEndpoint("/repos/"+p.Repo+"/collaborators/"+username, nil)
319 result := requests.New(endpoint.String()).
320 WithContext(ctx).
321 WithHeaders(makeGitHubHeader(accessToken)).
322 Do()
323 if result.Error() != nil {
324 return false, result.Error()
325 }
327 if result.StatusCode() != 204 {
328 return false, fmt.Errorf("got %d from %q %s",
329 result.StatusCode(), endpoint.String(), result.Body())
330 }
332 logger.Printf("got %d from %q %s", result.StatusCode(), endpoint.String(), result.Body())
334 return true, nil
337// getEmail updates the SessionState Email
338func (p *GitHubProvider) getEmail(ctx context.Context, s *sessions.SessionState) error {
340 var emails []struct {
341 Email string `json:"email"`
342 Primary bool `json:"primary"`
343 Verified bool `json:"verified"`
344 }
346 endpoint := p.makeGitHubAPIEndpoint("/user/emails", nil)
348 err := requests.New(endpoint.String()).
349 WithContext(ctx).
350 WithHeaders(makeGitHubHeader(s.AccessToken)).
351 Do().
352 UnmarshalInto(&emails)
353 if err != nil {
354 return err
355 }
357 for _, email := range emails {
358 if email.Verified {
359 if email.Primary {
360 s.Email = email.Email
361 return nil
362 }
363 }
364 }
366 return nil
369// getUser updates the SessionState User
370func (p *GitHubProvider) getUser(ctx context.Context, s *sessions.SessionState) error {
371 var user struct {
372 Login string `json:"login"`
373 Email string `json:"email"`
374 }
376 endpoint := p.makeGitHubAPIEndpoint("/user", nil)
378 err := requests.New(endpoint.String()).
379 WithContext(ctx).
380 WithHeaders(makeGitHubHeader(s.AccessToken)).
381 Do().
382 UnmarshalInto(&user)
383 if err != nil {
384 return err
385 }
387 // Now that we have the username we can check collaborator status
388 if !p.isVerifiedUser(user.Login) && p.Org == "" && p.Repo != "" && p.Token != "" {
389 if ok, err := p.isCollaborator(ctx, user.Login, p.Token); err != nil || !ok {
390 return err
391 }
392 }
394 s.User = user.Login
395 return nil
398func (p *GitHubProvider) isVerifiedUser(username string) bool {
399 for _, u := range p.Users {
400 if username == u {
401 return true
402 }
403 }
404 return false
407func (p *GitHubProvider) checkRestrictions(ctx context.Context, s *sessions.SessionState) error {
408 // If a user is verified by username options, skip the following restrictions
409 if ok, err := p.checkUserRestriction(ctx, s); err != nil || ok {
410 return err
411 }
413 var err error
414 switch {
415 case p.Org != "" && p.Team != "":
416 err = p.hasOrgAndTeam(s)
417 case p.Org != "":
418 err = p.hasOrg(s)
419 case p.Team != "":
420 err = p.hasTeam(s)
421 }
423 if err != nil {
424 return err
425 }
427 if p.Org == "" && p.Repo != "" && p.Token == "" {
428 // If we have a token we'll do the collaborator check
429 return p.hasRepoAccess(ctx, s.AccessToken)
430 }
432 return nil
435func (p *GitHubProvider) checkUserRestriction(ctx context.Context, s *sessions.SessionState) (bool, error) {
436 if len(p.Users) == 0 {
437 return false, nil
438 }
440 verifiedUser, err := p.hasUser(ctx, s.AccessToken)
441 if err != nil {
442 return verifiedUser, err
443 }
445 // org and repository options are not configured
446 if !verifiedUser && p.Org == "" && p.Repo == "" {
447 return false, errors.New("missing github user")
448 }
450 return verifiedUser, nil
453func (p *GitHubProvider) getOrgAndTeam(ctx context.Context, s *sessions.SessionState) error {
454 err := p.getOrgs(ctx, s)
455 if err != nil {
456 return err
457 }
459 return p.getTeams(ctx, s)
462func (p *GitHubProvider) getOrgs(ctx context.Context, s *sessions.SessionState) error {
464 type Organization struct {
465 // Support for Github organizations
466 // https://docs.github.com/en/rest/orgs/orgs#list-organizations-for-the-authenticated-user
467 Login string `json:"login,omitempty"`
468 // Support for Gitea organizations
469 // https://docs.gitea.com/api/1.24/#tag/organization/operation/orgGetAll
470 Name string `json:"name,omitempty"`
471 }
473 pn := 1
474 for {
475 params := url.Values{
476 "per_page": {"100"},
477 "page": {strconv.Itoa(pn)},
478 }
480 endpoint := p.makeGitHubAPIEndpoint("/user/orgs", &params)
482 var orgs []Organization
483 err := requests.New(endpoint.String()).
484 WithContext(ctx).
485 WithHeaders(makeGitHubHeader(s.AccessToken)).
486 Do().
487 UnmarshalInto(&orgs)
488 if err != nil {
489 return err
490 }
492 if len(orgs) == 0 {
493 break
494 }
496 for _, org := range orgs {
497 var orgName string
498 if len(org.Login) > 0 {
499 orgName = org.Login
500 logger.Printf("Member of Github Organization: %q", orgName)
501 } else {
502 orgName = org.Name
503 logger.Printf("Member of Gitea Organization: %q", orgName)
504 }
506 s.Groups = append(s.Groups, orgName)
507 }
508 pn++
509 }
511 return nil
514func (p *GitHubProvider) getTeams(ctx context.Context, s *sessions.SessionState) error {
515 // https://docs.github.com/en/rest/teams/teams?#list-user-teams
516 type Team struct {
517 Name string `json:"name"`
518 Slug string `json:"slug"`
519 Org struct {
520 Login string `json:"login"`
521 Name string `json:"name"`
522 } `json:"organization"`
523 }
525 pn := 1
526 for {
527 params := url.Values{
528 "per_page": {"100"},
529 "page": {strconv.Itoa(pn)},
530 }
532 endpoint := p.makeGitHubAPIEndpoint("/user/teams", &params)
534 var teams []Team
535 err := requests.New(endpoint.String()).
536 WithContext(ctx).
537 WithHeaders(makeGitHubHeader(s.AccessToken)).
538 Do().
539 UnmarshalInto(&teams)
540 if err != nil {
541 return err
542 }
544 if len(teams) == 0 {
545 break
546 }
548 for _, team := range teams {
549 var orgName, teamName string
551 if len(team.Org.Login) > 0 {
552 orgName = team.Org.Login
553 teamName = team.Slug
554 logger.Printf("Member of Github Organization/Team: %q/%q", orgName, teamName)
555 } else {
556 orgName = team.Org.Name
557 teamName = team.Name
558 logger.Printf("Member of Gitea Organization/Team: %q/%q", orgName, teamName)
559 }
561 s.Groups = append(s.Groups, fmt.Sprintf("%s%s%s", orgName, orgTeamSeparator, teamName))
562 }
564 pn++
565 }
567 return nil