git.iko.soy/typeable/oauth2-proxy
on f201dff help sign in
514 lines 13 kB history raw
1package providers
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net/http"
8 "net/url"
9 "path"
10 "regexp"
11 "strconv"
12 "strings"
14 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
15 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
16 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
17 "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests"
18 "golang.org/x/exp/maps"
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
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
208 }
209 }
210 presentTeams = append(presentTeams, ot.Team)
211 }
212 }
214 if hasOrg {
215 logger.Printf("Missing Team:%q from Org:%q in teams: %v", p.Team, p.Org, presentTeams)
216 return errors.New("user is missing required team")
217 }
219 logger.Printf("Missing Organization:%q in %#v", p.Org, maps.Keys(presentOrgs))
220 return errors.New("user is missing required organization")
223func (p *GitHubProvider) hasRepoAccess(ctx context.Context, accessToken string) error {
224 // https://developer.github.com/v3/repos/#get-a-repository
226 type permissions struct {
227 Pull bool `json:"pull"`
228 Push bool `json:"push"`
229 }
231 type repository struct {
232 Permissions permissions `json:"permissions"`
233 Private bool `json:"private"`
234 }
236 endpoint := p.makeGitHubAPIEndpoint("/repos/"+p.Repo, nil)
238 var repo repository
239 err := requests.New(endpoint.String()).
240 WithContext(ctx).
241 WithHeaders(makeGitHubHeader(accessToken)).
242 Do().
243 UnmarshalInto(&repo)
245 if err != nil {
246 return err
247 }
249 // Every user can implicitly pull from a public repo, so only grant access
250 // if they have push access or the repo is private and they can pull
251 if repo.Permissions.Push || (repo.Private && repo.Permissions.Pull) {
252 return nil
253 }
255 return errors.New("user doesn't have repository access")
258func (p *GitHubProvider) hasUser(ctx context.Context, accessToken string) (bool, error) {
259 // https://developer.github.com/v3/users/#get-the-authenticated-user
261 var user struct {
262 Login string `json:"login"`
263 Email string `json:"email"`
264 }
266 endpoint := p.makeGitHubAPIEndpoint("/user", nil)
268 err := requests.New(endpoint.String()).
269 WithContext(ctx).
270 WithHeaders(makeGitHubHeader(accessToken)).
271 Do().
272 UnmarshalInto(&user)
273 if err != nil {
274 return false, err
275 }
277 if p.isVerifiedUser(user.Login) {
278 return true, nil
279 }
280 return false, nil
283func (p *GitHubProvider) isCollaborator(ctx context.Context, username, accessToken string) (bool, error) {
284 //https://developer.github.com/v3/repos/collaborators/#check-if-a-user-is-a-collaborator
286 endpoint := p.makeGitHubAPIEndpoint("/repos/"+p.Repo+"/collaborators/"+username, nil)
287 result := requests.New(endpoint.String()).
288 WithContext(ctx).
289 WithHeaders(makeGitHubHeader(accessToken)).
290 Do()
291 if result.Error() != nil {
292 return false, result.Error()
293 }
295 if result.StatusCode() != 204 {
296 return false, fmt.Errorf("got %d from %q %s",
297 result.StatusCode(), endpoint.String(), result.Body())
298 }
300 logger.Printf("got %d from %q %s", result.StatusCode(), endpoint.String(), result.Body())
302 return true, nil
305// getEmail updates the SessionState Email
306func (p *GitHubProvider) getEmail(ctx context.Context, s *sessions.SessionState) error {
308 var emails []struct {
309 Email string `json:"email"`
310 Primary bool `json:"primary"`
311 Verified bool `json:"verified"`
312 }
314 endpoint := p.makeGitHubAPIEndpoint("/user/emails", nil)
316 err := requests.New(endpoint.String()).
317 WithContext(ctx).
318 WithHeaders(makeGitHubHeader(s.AccessToken)).
319 Do().
320 UnmarshalInto(&emails)
321 if err != nil {
322 return err
323 }
325 for _, email := range emails {
326 if email.Verified {
327 if email.Primary {
328 s.Email = email.Email
329 return nil
330 }
331 }
332 }
334 return nil
337// getUser updates the SessionState User
338func (p *GitHubProvider) getUser(ctx context.Context, s *sessions.SessionState) error {
339 var user struct {
340 Login string `json:"login"`
341 Email string `json:"email"`
342 }
344 endpoint := p.makeGitHubAPIEndpoint("/user", nil)
346 err := requests.New(endpoint.String()).
347 WithContext(ctx).
348 WithHeaders(makeGitHubHeader(s.AccessToken)).
349 Do().
350 UnmarshalInto(&user)
351 if err != nil {
352 return err
353 }
355 // Now that we have the username we can check collaborator status
356 if !p.isVerifiedUser(user.Login) && p.Org == "" && p.Repo != "" && p.Token != "" {
357 if ok, err := p.isCollaborator(ctx, user.Login, p.Token); err != nil || !ok {
358 return err
359 }
360 }
362 s.User = user.Login
363 return nil
366func (p *GitHubProvider) isVerifiedUser(username string) bool {
367 for _, u := range p.Users {
368 if username == u {
369 return true
370 }
371 }
372 return false
375func (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 }
381 if err := p.hasOrgAndTeamAccess(s); err != nil {
382 return err
383 }
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 }
390 return nil
393func (p *GitHubProvider) checkUserRestriction(ctx context.Context, s *sessions.SessionState) (bool, error) {
394 if len(p.Users) == 0 {
395 return false, nil
396 }
398 verifiedUser, err := p.hasUser(ctx, s.AccessToken)
399 if err != nil {
400 return verifiedUser, err
401 }
403 // org and repository options are not configured
404 if !verifiedUser && p.Org == "" && p.Repo == "" {
405 return false, errors.New("missing github user")
406 }
408 return verifiedUser, nil
411func (p *GitHubProvider) hasOrgAndTeamAccess(s *sessions.SessionState) error {
412 if p.Org != "" && p.Team != "" {
413 return p.hasOrgAndTeam(s)
414 }
416 if p.Org != "" {
417 return p.hasOrg(s)
418 }
420 return nil
423func (p *GitHubProvider) getOrgAndTeam(ctx context.Context, s *sessions.SessionState) error {
424 err := p.getOrgs(ctx, s)
425 if err != nil {
426 return err
427 }
429 return p.getTeams(ctx, s)
432func (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
435 type Organization struct {
436 Login string `json:"login"`
437 }
439 pn := 1
440 for {
441 params := url.Values{
442 "per_page": {"100"},
443 "page": {strconv.Itoa(pn)},
444 }
446 endpoint := p.makeGitHubAPIEndpoint("/user/orgs", &params)
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 }
458 if len(orgs) == 0 {
459 break
460 }
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 }
469 return nil
472func (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 }
482 pn := 1
483 for {
484 params := url.Values{
485 "per_page": {"100"},
486 "page": {strconv.Itoa(pn)},
487 }
489 endpoint := p.makeGitHubAPIEndpoint("/user/teams", &params)
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 }
501 if len(teams) == 0 {
502 break
503 }
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 }
510 pn++
511 }
513 return nil