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