git.iko.soy/typeable/oauth2-proxy
on master help sign in
Restrict access using Github collaborators (#497)

* Allow access based on Github repository

commit7cf685140bd096cbb33e00365bdb964c21422edc authorJohn Clayton <john@codemonkeylabs.com> dateMon 11 May 2020, 11:02 (6 years ago) parente642dae
8 files changed+263−14
CHANGELOG.md+1 contrib/oauth2-proxy_autocomplete.sh+1−1 docs/2_auth.md+14−1 docs/configuration/configuration.md+2 main.go+2 options.go+3 providers/github.go+97−2 providers/github_test.go+143−10
CHANGELOG.md+1
@@ -58,6 +58,7 @@
5858- [#483](https://github.com/oauth2-proxy/oauth2-proxy/pull/483) Warn users when session cookies are split (@JoelSpeed)
5959- [#488](https://github.com/oauth2-proxy/oauth2-proxy/pull/488) Set-Basic-Auth should default to false (@JoelSpeed)
6060- [#494](https://github.com/oauth2-proxy/oauth2-proxy/pull/494) Upstream websockets TLS certificate validation now depends on ssl-upstream-insecure-skip-verify (@yaroslavros)
61+- [#497](https://github.com/oauth2-proxy/oauth2-proxy/pull/497) Restrict access using Github collaborators (@jsclayton)
6162
6263# v5.1.1
6364
contrib/oauth2-proxy_autocomplete.sh+1−1
@@ -20,7 +20,7 @@ _oauth2_proxy() {
2020 COMPREPLY=( $(compgen -W "google azure facebook github keycloak gitlab linkedin login.gov digitalocean" -- ${cur}) )
2121 return 0
2222 ;;
23− -@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|keycloak-group|azure-tenant|bitbucket-team|bitbucket-repository|github-org|github-team|gitlab-group|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|cookie-samesite|redist-sentinel-master-name|redist-sentinel-connection-urls|redist-cluster-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url))
23+ -@(http-address|https-address|redirect-url|upstream|basic-auth-password|skip-auth-regex|flush-interval|extra-jwt-issuers|email-domain|whitelist-domain|keycloak-group|azure-tenant|bitbucket-team|bitbucket-repository|github-org|github-team|github-repo|github-token|gitlab-group|google-group|google-admin-email|google-service-account-json|client-id|client_secret|banner|footer|proxy-prefix|ping-path|cookie-name|cookie-secret|cookie-domain|cookie-path|cookie-expire|cookie-refresh|cookie-samesite|redist-sentinel-master-name|redist-sentinel-connection-urls|redist-cluster-connection-urls|logging-max-size|logging-max-age|logging-max-backups|standard-logging-format|request-logging-format|exclude-logging-paths|auth-logging-format|oidc-issuer-url|oidc-jwks-url|login-url|redeem-url|profile-url|resource|validate-url|scope|approval-prompt|signature-key|acr-values|jwt-key|pubjwk-url))
2424 return 0
2525 ;;
2626 esac
docs/2_auth.md+14−1
@@ -101,11 +101,24 @@ Note: When using the Azure Auth provider with nginx and the cookie session store
1011011. Create a new project: https://github.com/settings/developers
1021022. Under `Authorization callback URL` enter the correct url ie `https://internal.yourcompany.com/oauth2/callback`
103103
104−The GitHub auth provider supports two additional parameters to restrict authentication to Organization or Team level access. Restricting by org and team is normally accompanied with `--email-domain=*`
104+The GitHub auth provider supports two additional ways to restrict authentication to either organization and optional team level access, or to collaborators of a repository. Restricting by these options is normally accompanied with `--email-domain=*`
105+
106+To restrict by organization only, include the following flag:
105107
106108 -github-org="": restrict logins to members of this organisation
109+
110+To restrict within an organization to specific teams, include the following flag in addition to `-github-org`:
111+
107112 -github-team="": restrict logins to members of any of these teams (slug), separated by a comma
108113
114+If you would rather restrict access to collaborators of a repository, those users must either have push access to a public repository or any access to a private repository:
115+
116+ -github-repo="": restrict logins to collaborators of this repository formatted as orgname/repo
117+
118+If you'd like to allow access to users with **read only** access to a **public** repository you will need to provide a [token](https://github.com/settings/tokens) for a user that has write access to the repository. The token must be created with at least the `public_repo` scope:
119+
120+ -github-token="": the token to use when verifying repository collaborators
121+
109122If you are using GitHub enterprise, make sure you set the following to the appropriate url:
110123
111124 -login-url="http(s)://<enterprise github host>/login/oauth/authorize"
docs/configuration/configuration.md+2
@@ -54,6 +54,8 @@ An example [oauth2-proxy.cfg]({{ site.gitweb }}/contrib/oauth2-proxy.cfg.example
5454| `--gcp-healthchecks` | bool | will enable `/liveness_check`, `/readiness_check`, and `/` (with the proper user-agent) endpoints that will make it work well with GCP App Engine and GKE Ingresses | false |
5555| `--github-org` | string | restrict logins to members of this organisation | |
5656| `--github-team` | string | restrict logins to members of any of these teams (slug), separated by a comma | |
57+| `--github-repo` | string | restrict logins to collaborators of this repository formatted as `orgname/repo` | |
58+| `--github-token` | string | the token to use when verifying repository collaborators (must have push access to the repository) | |
5759| `--gitlab-group` | string | restrict logins to members of any of these groups (slug), separated by a comma | |
5860| `--google-admin-email` | string | the google admin to impersonate for api calls | |
5961| `--google-group` | string | restrict logins to members of this google group (may be given multiple times). | |
main.go+2
@@ -58,6 +58,8 @@ func main() {
5858 flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
5959 flagSet.String("github-org", "", "restrict logins to members of this organisation")
6060 flagSet.String("github-team", "", "restrict logins to members of this team")
61+ flagSet.String("github-repo", "", "restrict logins to collaborators of this repository")
62+ flagSet.String("github-token", "", "the token to use when verifying repository collaborators (must have push access to the repository)")
6163 flagSet.String("gitlab-group", "", "restrict logins to members of this group")
6264 flagSet.StringSlice("google-group", []string{}, "restrict logins to members of this google group (may be given multiple times).")
6365 flagSet.String("google-admin-email", "", "the google admin to impersonate for api calls")
options.go+3
@@ -54,6 +54,8 @@ type Options struct {
5454 WhitelistDomains []string `flag:"whitelist-domain" cfg:"whitelist_domains" env:"OAUTH2_PROXY_WHITELIST_DOMAINS"`
5555 GitHubOrg string `flag:"github-org" cfg:"github_org" env:"OAUTH2_PROXY_GITHUB_ORG"`
5656 GitHubTeam string `flag:"github-team" cfg:"github_team" env:"OAUTH2_PROXY_GITHUB_TEAM"`
57+ GitHubRepo string `flag:"github-repo" cfg:"github_repo" env:"OAUTH2_PROXY_GITHUB_REPO"`
58+ GitHubToken string `flag:"github-token" cfg:"github_token" env:"OAUTH2_PROXY_GITHUB_TOKEN"`
5759 GitLabGroup string `flag:"gitlab-group" cfg:"gitlab_group" env:"OAUTH2_PROXY_GITLAB_GROUP"`
5860 GoogleGroups []string `flag:"google-group" cfg:"google_group" env:"OAUTH2_PROXY_GOOGLE_GROUPS"`
5961 GoogleAdminEmail string `flag:"google-admin-email" cfg:"google_admin_email" env:"OAUTH2_PROXY_GOOGLE_ADMIN_EMAIL"`
@@ -483,6 +485,7 @@ func parseProviderInfo(o *Options, msgs []string) []string {
483485 p.Configure(o.AzureTenant)
484486 case *providers.GitHubProvider:
485487 p.SetOrgTeam(o.GitHubOrg, o.GitHubTeam)
488+ p.SetRepo(o.GitHubRepo, o.GitHubToken)
486489 case *providers.KeycloakProvider:
487490 p.SetGroup(o.KeycloakGroup)
488491 case *providers.GoogleProvider:
providers/github.go+97−2
@@ -19,8 +19,10 @@ import (
1919// GitHubProvider represents an GitHub based Identity Provider
2020type GitHubProvider struct {
2121 *ProviderData
22− Org string
23− Team string
22+ Org string
23+ Team string
24+ Repo string
25+ Token string
2426}
2527
2628var _ Provider = (*GitHubProvider)(nil)
@@ -72,6 +74,12 @@ func (p *GitHubProvider) SetOrgTeam(org, team string) {
7274 }
7375}
7476
77+// SetRepo configures the target repository and optional token to use
78+func (p *GitHubProvider) SetRepo(repo, token string) {
79+ p.Repo = repo
80+ p.Token = token
81+}
82+
7583func (p *GitHubProvider) hasOrg(ctx context.Context, accessToken string) (bool, error) {
7684 // https://developer.github.com/v3/orgs/#list-your-organizations
7785
@@ -263,6 +271,82 @@ func (p *GitHubProvider) hasOrgAndTeam(ctx context.Context, accessToken string)
263271 return false, nil
264272}
265273
274+func (p *GitHubProvider) hasRepo(ctx context.Context, accessToken string) (bool, error) {
275+ // https://developer.github.com/v3/repos/#get-a-repository
276+
277+ type permissions struct {
278+ Pull bool `json:"pull"`
279+ Push bool `json:"push"`
280+ }
281+
282+ type repository struct {
283+ Permissions permissions `json:"permissions"`
284+ Private bool `json:"private"`
285+ }
286+
287+ endpoint := &url.URL{
288+ Scheme: p.ValidateURL.Scheme,
289+ Host: p.ValidateURL.Host,
290+ Path: path.Join(p.ValidateURL.Path, "/repo/", p.Repo),
291+ }
292+
293+ req, _ := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil)
294+ req.Header = getGitHubHeader(accessToken)
295+ resp, err := http.DefaultClient.Do(req)
296+ if err != nil {
297+ return false, err
298+ }
299+
300+ body, err := ioutil.ReadAll(resp.Body)
301+ resp.Body.Close()
302+ if err != nil {
303+ return false, err
304+ }
305+ if resp.StatusCode != 200 {
306+ return false, fmt.Errorf(
307+ "got %d from %q %s", resp.StatusCode, endpoint.String(), body)
308+ }
309+
310+ var repo repository
311+ if err := json.Unmarshal(body, &repo); err != nil {
312+ return false, err
313+ }
314+
315+ // Every user can implicitly pull from a public repo, so only grant access
316+ // if they have push access or the repo is private and they can pull
317+ return repo.Permissions.Push || (repo.Private && repo.Permissions.Pull), nil
318+}
319+
320+func (p *GitHubProvider) isCollaborator(ctx context.Context, username, accessToken string) (bool, error) {
321+ //https://developer.github.com/v3/repos/collaborators/#check-if-a-user-is-a-collaborator
322+
323+ endpoint := &url.URL{
324+ Scheme: p.ValidateURL.Scheme,
325+ Host: p.ValidateURL.Host,
326+ Path: path.Join(p.ValidateURL.Path, "/repos/", p.Repo, "/collaborators/", username),
327+ }
328+ req, _ := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil)
329+ req.Header = getGitHubHeader(accessToken)
330+ resp, err := http.DefaultClient.Do(req)
331+ if err != nil {
332+ return false, err
333+ }
334+ body, err := ioutil.ReadAll(resp.Body)
335+ resp.Body.Close()
336+ if err != nil {
337+ return false, err
338+ }
339+
340+ if resp.StatusCode != 204 {
341+ return false, fmt.Errorf("got %d from %q %s",
342+ resp.StatusCode, endpoint.String(), body)
343+ }
344+
345+ logger.Printf("got %d from %q %s", resp.StatusCode, endpoint.String(), body)
346+
347+ return true, nil
348+}
349+
266350// GetEmailAddress returns the Account email address
267351func (p *GitHubProvider) GetEmailAddress(ctx context.Context, s *sessions.SessionState) (string, error) {
268352
@@ -283,6 +367,10 @@ func (p *GitHubProvider) GetEmailAddress(ctx context.Context, s *sessions.Sessio
283367 return "", err
284368 }
285369 }
370+ } else if p.Repo != "" && p.Token == "" { // If we have a token we'll do the collaborator check in GetUserName
371+ if ok, err := p.hasRepo(ctx, s.AccessToken); err != nil || !ok {
372+ return "", err
373+ }
286374 }
287375
288376 endpoint := &url.URL{
@@ -367,6 +455,13 @@ func (p *GitHubProvider) GetUserName(ctx context.Context, s *sessions.SessionSta
367455 return "", fmt.Errorf("%s unmarshaling %s", err, body)
368456 }
369457
458+ // Now that we have the username we can check collaborator status
459+ if p.Org == "" && p.Repo != "" && p.Token != "" {
460+ if ok, err := p.isCollaborator(ctx, user.Login, p.Token); err != nil || !ok {
461+ return "", err
462+ }
463+ }
464+
370465 return user.Login, nil
371466}
372467
providers/github_test.go+143−10
@@ -29,8 +29,10 @@ func testGitHubProvider(hostname string) *GitHubProvider {
2929 return p
3030}
3131
32−func testGitHubBackend(payload []string) *httptest.Server {
32+func testGitHubBackend(payloads map[string][]string) *httptest.Server {
3333 pathToQueryMap := map[string][]string{
34+ "/repo/oauth2-proxy/oauth2-proxy": {""},
35+ "/repos/oauth2-proxy/oauth2-proxy/collaborators/mbland": {""},
3436 "/user": {""},
3537 "/user/emails": {""},
3638 "/user/orgs": {"page=1&per_page=100", "page=2&per_page=100", "page=3&per_page=100"},
@@ -47,10 +49,16 @@ func testGitHubBackend(payload []string) *httptest.Server {
4749 index = i
4850 }
4951 }
52+ payload := []string{}
53+ if ok && validQuery {
54+ payload, ok = payloads[r.URL.Path]
55+ }
5056 if !ok {
5157 w.WriteHeader(404)
5258 } else if !validQuery {
5359 w.WriteHeader(404)
60+ } else if payload[index] == "" {
61+ w.WriteHeader(204)
5462 } else {
5563 w.WriteHeader(200)
5664 w.Write([]byte(payload[index]))
@@ -99,7 +107,9 @@ func TestGitHubProviderOverrides(t *testing.T) {
99107}
100108
101109func TestGitHubProviderGetEmailAddress(t *testing.T) {
102− b := testGitHubBackend([]string{`[ {"email": "michael.bland@gsa.gov", "verified": true, "primary": true} ]`})
110+ b := testGitHubBackend(map[string][]string{
111+ "/user/emails": {`[ {"email": "michael.bland@gsa.gov", "verified": true, "primary": true} ]`},
112+ })
103113 defer b.Close()
104114
105115 bURL, _ := url.Parse(b.URL)
@@ -112,7 +122,9 @@ func TestGitHubProviderGetEmailAddress(t *testing.T) {
112122}
113123
114124func TestGitHubProviderGetEmailAddressNotVerified(t *testing.T) {
115− b := testGitHubBackend([]string{`[ {"email": "michael.bland@gsa.gov", "verified": false, "primary": true} ]`})
125+ b := testGitHubBackend(map[string][]string{
126+ "/user/emails": {`[ {"email": "michael.bland@gsa.gov", "verified": true, "primary": true} ]`},
127+ })
116128 defer b.Close()
117129
118130 bURL, _ := url.Parse(b.URL)
@@ -125,10 +137,13 @@ func TestGitHubProviderGetEmailAddressNotVerified(t *testing.T) {
125137}
126138
127139func TestGitHubProviderGetEmailAddressWithOrg(t *testing.T) {
128− b := testGitHubBackend([]string{
129− `[ {"email": "michael.bland@gsa.gov", "primary": true, "verified": true, "login":"testorg"} ]`,
130− `[ {"email": "michael.bland1@gsa.gov", "primary": true, "verified": true, "login":"testorg1"} ]`,
131− `[ ]`,
140+ b := testGitHubBackend(map[string][]string{
141+ "/user/emails": {`[ {"email": "michael.bland@gsa.gov", "verified": true, "primary": true} ]`},
142+ "/user/orgs": {
143+ `[ {"login":"testorg"} ]`,
144+ `[ {"login":"testorg1"} ]`,
145+ `[ ]`,
146+ },
132147 })
133148 defer b.Close()
134149
@@ -142,10 +157,93 @@ func TestGitHubProviderGetEmailAddressWithOrg(t *testing.T) {
142157 assert.Equal(t, "michael.bland@gsa.gov", email)
143158}
144159
160+func TestGitHubProviderGetEmailAddressWithWriteAccessToPublicRepo(t *testing.T) {
161+ b := testGitHubBackend(map[string][]string{
162+ "/repo/oauth2-proxy/oauth2-proxy": {`{"permissions": {"pull": true, "push": true}, "private": false}`},
163+ "/user/emails": {`[ {"email": "michael.bland@gsa.gov", "verified": true, "primary": true} ]`},
164+ })
165+ defer b.Close()
166+
167+ bURL, _ := url.Parse(b.URL)
168+ p := testGitHubProvider(bURL.Host)
169+ p.SetRepo("oauth2-proxy/oauth2-proxy", "")
170+
171+ session := CreateAuthorizedSession()
172+ email, err := p.GetEmailAddress(context.Background(), session)
173+ assert.Equal(t, nil, err)
174+ assert.Equal(t, "michael.bland@gsa.gov", email)
175+}
176+
177+func TestGitHubProviderGetEmailAddressWithReadOnlyAccessToPrivateRepo(t *testing.T) {
178+ b := testGitHubBackend(map[string][]string{
179+ "/repo/oauth2-proxy/oauth2-proxy": {`{"permissions": {"pull": true, "push": false}, "private": true}`},
180+ "/user/emails": {`[ {"email": "michael.bland@gsa.gov", "verified": true, "primary": true} ]`},
181+ })
182+ defer b.Close()
183+
184+ bURL, _ := url.Parse(b.URL)
185+ p := testGitHubProvider(bURL.Host)
186+ p.SetRepo("oauth2-proxy/oauth2-proxy", "")
187+
188+ session := CreateAuthorizedSession()
189+ email, err := p.GetEmailAddress(context.Background(), session)
190+ assert.Equal(t, nil, err)
191+ assert.Equal(t, "michael.bland@gsa.gov", email)
192+}
193+
194+func TestGitHubProviderGetEmailAddressWithWriteAccessToPrivateRepo(t *testing.T) {
195+ b := testGitHubBackend(map[string][]string{
196+ "/repo/oauth2-proxy/oauth2-proxy": {`{"permissions": {"pull": true, "push": true}, "private": true}`},
197+ "/user/emails": {`[ {"email": "michael.bland@gsa.gov", "verified": true, "primary": true} ]`},
198+ })
199+ defer b.Close()
200+
201+ bURL, _ := url.Parse(b.URL)
202+ p := testGitHubProvider(bURL.Host)
203+ p.SetRepo("oauth2-proxy/oauth2-proxy", "")
204+
205+ session := CreateAuthorizedSession()
206+ email, err := p.GetEmailAddress(context.Background(), session)
207+ assert.Equal(t, nil, err)
208+ assert.Equal(t, "michael.bland@gsa.gov", email)
209+}
210+
211+func TestGitHubProviderGetEmailAddressWithNoAccessToPrivateRepo(t *testing.T) {
212+ b := testGitHubBackend(map[string][]string{
213+ "/repo/oauth2-proxy/oauth2-proxy": {},
214+ })
215+ defer b.Close()
216+
217+ bURL, _ := url.Parse(b.URL)
218+ p := testGitHubProvider(bURL.Host)
219+ p.SetRepo("oauth2-proxy/oauth2-proxy", "")
220+
221+ session := CreateAuthorizedSession()
222+ email, err := p.GetEmailAddress(context.Background(), session)
223+ assert.NotEqual(t, nil, err)
224+ assert.Equal(t, "", email)
225+}
226+
227+func TestGitHubProviderGetEmailAddressWithToken(t *testing.T) {
228+ b := testGitHubBackend(map[string][]string{
229+ "/user/emails": {`[ {"email": "michael.bland@gsa.gov", "verified": true, "primary": true} ]`},
230+ })
231+ defer b.Close()
232+
233+ bURL, _ := url.Parse(b.URL)
234+ p := testGitHubProvider(bURL.Host)
235+ p.SetRepo("oauth2-proxy/oauth2-proxy", "token")
236+
237+ session := CreateAuthorizedSession()
238+ email, err := p.GetEmailAddress(context.Background(), session)
239+ assert.Equal(t, nil, err)
240+ assert.Equal(t, "michael.bland@gsa.gov", email)
241+}
242+
145243// Note that trying to trigger the "failed building request" case is not
146244// practical, since the only way it can fail is if the URL fails to parse.
147245func TestGitHubProviderGetEmailAddressFailedRequest(t *testing.T) {
148− b := testGitHubBackend([]string{"unused payload"})
246+ b := testGitHubBackend(map[string][]string{})
149247 defer b.Close()
150248
151249 bURL, _ := url.Parse(b.URL)
@@ -161,7 +259,9 @@ func TestGitHubProviderGetEmailAddressFailedRequest(t *testing.T) {
161259}
162260
163261func TestGitHubProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) {
164− b := testGitHubBackend([]string{"{\"foo\": \"bar\"}"})
262+ b := testGitHubBackend(map[string][]string{
263+ "/user/emails": {`{"foo": "bar"}`},
264+ })
165265 defer b.Close()
166266
167267 bURL, _ := url.Parse(b.URL)
@@ -174,14 +274,47 @@ func TestGitHubProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) {
174274}
175275
176276func TestGitHubProviderGetUserName(t *testing.T) {
177− b := testGitHubBackend([]string{`{"email": "michael.bland@gsa.gov", "login": "mbland"}`})
277+ b := testGitHubBackend(map[string][]string{
278+ "/user": {`{"email": "michael.bland@gsa.gov", "login": "mbland"}`},
279+ })
280+ defer b.Close()
281+
282+ bURL, _ := url.Parse(b.URL)
283+ p := testGitHubProvider(bURL.Host)
284+
285+ session := CreateAuthorizedSession()
286+ email, err := p.GetUserName(context.Background(), session)
287+ assert.Equal(t, nil, err)
288+ assert.Equal(t, "mbland", email)
289+}
290+
291+func TestGitHubProviderGetUserNameWithRepoAndToken(t *testing.T) {
292+ b := testGitHubBackend(map[string][]string{
293+ "/user": {`{"email": "michael.bland@gsa.gov", "login": "mbland"}`},
294+ "/repos/oauth2-proxy/oauth2-proxy/collaborators/mbland": {""},
295+ })
178296 defer b.Close()
179297
180298 bURL, _ := url.Parse(b.URL)
181299 p := testGitHubProvider(bURL.Host)
300+ p.SetRepo("oauth2-proxy/oauth2-proxy", "token")
182301
183302 session := CreateAuthorizedSession()
184303 email, err := p.GetUserName(context.Background(), session)
185304 assert.Equal(t, nil, err)
186305 assert.Equal(t, "mbland", email)
187306}
307+
308+func TestGitHubProviderGetUserNameWithRepoAndTokenWithoutPushAccess(t *testing.T) {
309+ b := testGitHubBackend(map[string][]string{})
310+ defer b.Close()
311+
312+ bURL, _ := url.Parse(b.URL)
313+ p := testGitHubProvider(bURL.Host)
314+ p.SetRepo("oauth2-proxy/oauth2-proxy", "token")
315+
316+ session := CreateAuthorizedSession()
317+ email, err := p.GetUserName(context.Background(), session)
318+ assert.NotEqual(t, nil, err)
319+ assert.Equal(t, "", email)
320+}