git.iko.soy/typeable/oauth2-proxy
on master help sign in
Add Bitbucket provider. (#201)

Add a new provider for Bitbucket, can be configured from the options specifying team and/or repository that the user must be part/have access to in order to grant login.

commitfa6c4792a1e3f3b44950d6498bac7974b45380f1 authoraledeganopix4d <40891147+aledeganopix4d@users.noreply.github.com> dateFri 16 Aug 2019, 15:53 (7 years ago) parenta165928
7 files changed+350
.github/CODEOWNERS+4 CHANGELOG.md+4 main.go+2 options.go+5 providers/bitbucket.go+163 providers/bitbucket_test.go+170 providers/providers.go+2
.github/CODEOWNERS+4
@@ -10,3 +10,7 @@
1010# or the public devops channel at https://chat.18f.gov/).
1111providers/logingov.go @timothy-spencer
1212providers/logingov_test.go @timothy-spencer
13+
14+# Bitbucket provider
15+providers/bitbucket.go @aledeganopix4d
16+providers/bitbucket_test.go @aledeganopix4d
CHANGELOG.md+4
@@ -96,6 +96,10 @@
9696- [#198](https://github.com/pusher/oauth2_proxy/pull/198) Switch from gometalinter to golangci-lint (@steakunderscore)
9797- [#159](https://github.com/pusher/oauth2_proxy/pull/159) Add option to skip the OIDC provider verified email check: `--insecure-oidc-allow-unverified-email`
9898- [#210](https://github.com/pusher/oauth2_proxy/pull/210) Update base image from Alpine 3.9 to 3.10 (@steakunderscore)
99+- [#201](https://github.com/pusher/oauth2_proxy/pull/201) Add Bitbucket as new OAuth2 provider, accepts email, team and repository permissions to determine authorization (@aledeganopix4d)
100+ - Implement flags to enable Bitbucket authentication:
101+ - `-bitbucket-repository` Restrict authorization to users that can access this repository
102+ - `-bitbucket-team` Restrict authorization to users that are part of this Bitbucket team
99103- [#211](https://github.com/pusher/oauth2_proxy/pull/211) Switch from dep to go modules (@steakunderscore)
100104- [#145](https://github.com/pusher/oauth2_proxy/pull/145) Add support for OIDC UserInfo endpoint email verification (@rtluckie)
101105
main.go+2
@@ -56,6 +56,8 @@ func main() {
5656 flagSet.Var(&emailDomains, "email-domain", "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
5757 flagSet.Var(&whitelistDomains, "whitelist-domain", "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)")
5858 flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
59+ flagSet.String("bitbucket-team", "", "restrict logins to members of this team")
60+ flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
5961 flagSet.String("github-org", "", "restrict logins to members of this organisation")
6062 flagSet.String("github-team", "", "restrict logins to members of this team")
6163 flagSet.String("gitlab-group", "", "restrict logins to members of this group")
options.go+5
@@ -42,6 +42,8 @@ type Options struct {
4242
4343 AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file" env:"OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE"`
4444 AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant" env:"OAUTH2_PROXY_AZURE_TENANT"`
45+ BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team" env:"OAUTH2_PROXY_BITBUCKET_TEAM"`
46+ BitbucketRepository string `flag:"bitbucket-repository" cfg:"bitbucket_repository" env:"OAUTH2_PROXY_BITBUCKET_REPOSITORY"`
4547 EmailDomains []string `flag:"email-domain" cfg:"email_domains" env:"OAUTH2_PROXY_EMAIL_DOMAINS"`
4648 WhitelistDomains []string `flag:"whitelist-domain" cfg:"whitelist_domains" env:"OAUTH2_PROXY_WHITELIST_DOMAINS"`
4749 GitHubOrg string `flag:"github-org" cfg:"github_org" env:"OAUTH2_PROXY_GITHUB_ORG"`
@@ -405,6 +407,9 @@ func parseProviderInfo(o *Options, msgs []string) []string {
405407 p.SetGroupRestriction(o.GoogleGroups, o.GoogleAdminEmail, file)
406408 }
407409 }
410+ case *providers.BitbucketProvider:
411+ p.SetTeam(o.BitbucketTeam)
412+ p.SetRepository(o.BitbucketRepository)
408413 case *providers.OIDCProvider:
409414 p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
410415 if o.oidcVerifier == nil {
providers/bitbucket.gonew file+163
@@ -0,0 +1,163 @@
1+package providers
2+
3+import (
4+ "net/http"
5+ "net/url"
6+ "strings"
7+
8+ "github.com/pusher/oauth2_proxy/pkg/apis/sessions"
9+ "github.com/pusher/oauth2_proxy/pkg/logger"
10+ "github.com/pusher/oauth2_proxy/pkg/requests"
11+)
12+
13+// BitbucketProvider represents an Bitbucket based Identity Provider
14+type BitbucketProvider struct {
15+ *ProviderData
16+ Team string
17+ Repository string
18+}
19+
20+// NewBitbucketProvider initiates a new BitbucketProvider
21+func NewBitbucketProvider(p *ProviderData) *BitbucketProvider {
22+ p.ProviderName = "Bitbucket"
23+ if p.LoginURL == nil || p.LoginURL.String() == "" {
24+ p.LoginURL = &url.URL{
25+ Scheme: "https",
26+ Host: "bitbucket.org",
27+ Path: "/site/oauth2/authorize",
28+ }
29+ }
30+ if p.RedeemURL == nil || p.RedeemURL.String() == "" {
31+ p.RedeemURL = &url.URL{
32+ Scheme: "https",
33+ Host: "bitbucket.org",
34+ Path: "/site/oauth2/access_token",
35+ }
36+ }
37+ if p.ValidateURL == nil || p.ValidateURL.String() == "" {
38+ p.ValidateURL = &url.URL{
39+ Scheme: "https",
40+ Host: "api.bitbucket.org",
41+ Path: "/2.0/user/emails",
42+ }
43+ }
44+ if p.Scope == "" {
45+ p.Scope = "email"
46+ }
47+ return &BitbucketProvider{ProviderData: p}
48+}
49+
50+// SetTeam defines the Bitbucket team the user must be part of
51+func (p *BitbucketProvider) SetTeam(team string) {
52+ p.Team = team
53+ if !strings.Contains(p.Scope, "team") {
54+ p.Scope += " team"
55+ }
56+}
57+
58+// SetRepository defines the repository the user must have access to
59+func (p *BitbucketProvider) SetRepository(repository string) {
60+ p.Repository = repository
61+ if !strings.Contains(p.Scope, "repository") {
62+ p.Scope += " repository"
63+ }
64+}
65+
66+// GetEmailAddress returns the email of the authenticated user
67+func (p *BitbucketProvider) GetEmailAddress(s *sessions.SessionState) (string, error) {
68+
69+ var emails struct {
70+ Values []struct {
71+ Email string `json:"email"`
72+ Primary bool `json:"is_primary"`
73+ }
74+ }
75+ var teams struct {
76+ Values []struct {
77+ Name string `json:"username"`
78+ }
79+ }
80+ var repositories struct {
81+ Values []struct {
82+ FullName string `json:"full_name"`
83+ }
84+ }
85+ req, err := http.NewRequest("GET",
86+ p.ValidateURL.String()+"?access_token="+s.AccessToken, nil)
87+ if err != nil {
88+ logger.Printf("failed building request %s", err)
89+ return "", err
90+ }
91+ err = requests.RequestJSON(req, &emails)
92+ if err != nil {
93+ logger.Printf("failed making request %s", err)
94+ return "", err
95+ }
96+
97+ if p.Team != "" {
98+ teamURL := &url.URL{}
99+ *teamURL = *p.ValidateURL
100+ teamURL.Path = "/2.0/teams"
101+ req, err = http.NewRequest("GET",
102+ teamURL.String()+"?role=member&access_token="+s.AccessToken, nil)
103+ if err != nil {
104+ logger.Printf("failed building request %s", err)
105+ return "", err
106+ }
107+ err = requests.RequestJSON(req, &teams)
108+ if err != nil {
109+ logger.Printf("failed requesting teams membership %s", err)
110+ return "", err
111+ }
112+ var found = false
113+ for _, team := range teams.Values {
114+ if p.Team == team.Name {
115+ found = true
116+ break
117+ }
118+ }
119+ if found != true {
120+ logger.Print("team membership test failed, access denied")
121+ return "", nil
122+ }
123+ }
124+
125+ if p.Repository != "" {
126+ repositoriesURL := &url.URL{}
127+ *repositoriesURL = *p.ValidateURL
128+ repositoriesURL.Path = "/2.0/repositories/" + strings.Split(p.Repository, "/")[0]
129+ req, err = http.NewRequest("GET",
130+ repositoriesURL.String()+"?role=contributor"+
131+ "&q=full_name="+url.QueryEscape("\""+p.Repository+"\"")+
132+ "&access_token="+s.AccessToken,
133+ nil)
134+ if err != nil {
135+ logger.Printf("failed building request %s", err)
136+ return "", err
137+ }
138+ err = requests.RequestJSON(req, &repositories)
139+ if err != nil {
140+ logger.Printf("failed checking repository access %s", err)
141+ return "", err
142+ }
143+ var found = false
144+ for _, repository := range repositories.Values {
145+ if p.Repository == repository.FullName {
146+ found = true
147+ break
148+ }
149+ }
150+ if found != true {
151+ logger.Print("repository access test failed, access denied")
152+ return "", nil
153+ }
154+ }
155+
156+ for _, email := range emails.Values {
157+ if email.Primary {
158+ return email.Email, nil
159+ }
160+ }
161+
162+ return "", nil
163+}
providers/bitbucket_test.gonew file+170
@@ -0,0 +1,170 @@
1+package providers
2+
3+import (
4+ "log"
5+ "net/http"
6+ "net/http/httptest"
7+ "net/url"
8+ "testing"
9+
10+ "github.com/stretchr/testify/assert"
11+
12+ "github.com/pusher/oauth2_proxy/pkg/apis/sessions"
13+)
14+
15+func testBitbucketProvider(hostname, team string, repository string) *BitbucketProvider {
16+ p := NewBitbucketProvider(
17+ &ProviderData{
18+ ProviderName: "",
19+ LoginURL: &url.URL{},
20+ RedeemURL: &url.URL{},
21+ ProfileURL: &url.URL{},
22+ ValidateURL: &url.URL{},
23+ Scope: ""})
24+
25+ if team != "" {
26+ p.SetTeam(team)
27+ }
28+
29+ if repository != "" {
30+ p.SetRepository(repository)
31+ }
32+
33+ if hostname != "" {
34+ updateURL(p.Data().LoginURL, hostname)
35+ updateURL(p.Data().RedeemURL, hostname)
36+ updateURL(p.Data().ProfileURL, hostname)
37+ updateURL(p.Data().ValidateURL, hostname)
38+ }
39+ return p
40+}
41+
42+func testBitbucketBackend(payload string) *httptest.Server {
43+ paths := map[string]bool{
44+ "/2.0/user/emails": true,
45+ "/2.0/teams": true,
46+ }
47+
48+ return httptest.NewServer(http.HandlerFunc(
49+ func(w http.ResponseWriter, r *http.Request) {
50+ url := r.URL
51+ if !paths[url.Path] {
52+ log.Printf("%s not in %+v\n", url.Path, paths)
53+ w.WriteHeader(404)
54+ } else if r.URL.Query().Get("access_token") != "imaginary_access_token" {
55+ w.WriteHeader(403)
56+ } else {
57+ w.WriteHeader(200)
58+ w.Write([]byte(payload))
59+ }
60+ }))
61+}
62+
63+func TestBitbucketProviderDefaults(t *testing.T) {
64+ p := testBitbucketProvider("", "", "")
65+ assert.NotEqual(t, nil, p)
66+ assert.Equal(t, "Bitbucket", p.Data().ProviderName)
67+ assert.Equal(t, "https://bitbucket.org/site/oauth2/authorize",
68+ p.Data().LoginURL.String())
69+ assert.Equal(t, "https://bitbucket.org/site/oauth2/access_token",
70+ p.Data().RedeemURL.String())
71+ assert.Equal(t, "https://api.bitbucket.org/2.0/user/emails",
72+ p.Data().ValidateURL.String())
73+ assert.Equal(t, "email", p.Data().Scope)
74+}
75+
76+func TestBitbucketProviderScopeAdjustForTeam(t *testing.T) {
77+ p := testBitbucketProvider("", "test-team", "")
78+ assert.NotEqual(t, nil, p)
79+ assert.Equal(t, "email team", p.Data().Scope)
80+}
81+
82+func TestBitbucketProviderScopeAdjustForRepository(t *testing.T) {
83+ p := testBitbucketProvider("", "", "rest-repo")
84+ assert.NotEqual(t, nil, p)
85+ assert.Equal(t, "email repository", p.Data().Scope)
86+}
87+
88+func TestBitbucketProviderOverrides(t *testing.T) {
89+ p := NewBitbucketProvider(
90+ &ProviderData{
91+ LoginURL: &url.URL{
92+ Scheme: "https",
93+ Host: "example.com",
94+ Path: "/oauth/auth"},
95+ RedeemURL: &url.URL{
96+ Scheme: "https",
97+ Host: "example.com",
98+ Path: "/oauth/token"},
99+ ValidateURL: &url.URL{
100+ Scheme: "https",
101+ Host: "example.com",
102+ Path: "/api/v3/user"},
103+ Scope: "profile"})
104+ assert.NotEqual(t, nil, p)
105+ assert.Equal(t, "Bitbucket", p.Data().ProviderName)
106+ assert.Equal(t, "https://example.com/oauth/auth",
107+ p.Data().LoginURL.String())
108+ assert.Equal(t, "https://example.com/oauth/token",
109+ p.Data().RedeemURL.String())
110+ assert.Equal(t, "https://example.com/api/v3/user",
111+ p.Data().ValidateURL.String())
112+ assert.Equal(t, "profile", p.Data().Scope)
113+}
114+
115+func TestBitbucketProviderGetEmailAddress(t *testing.T) {
116+ b := testBitbucketBackend("{\"values\": [ { \"email\": \"michael.bland@gsa.gov\", \"is_primary\": true } ] }")
117+ defer b.Close()
118+
119+ bURL, _ := url.Parse(b.URL)
120+ p := testBitbucketProvider(bURL.Host, "", "")
121+
122+ session := &sessions.SessionState{AccessToken: "imaginary_access_token"}
123+ email, err := p.GetEmailAddress(session)
124+ assert.Equal(t, nil, err)
125+ assert.Equal(t, "michael.bland@gsa.gov", email)
126+}
127+
128+func TestBitbucketProviderGetEmailAddressAndGroup(t *testing.T) {
129+ b := testBitbucketBackend("{\"values\": [ { \"email\": \"michael.bland@gsa.gov\", \"is_primary\": true, \"username\": \"bioinformatics\" } ] }")
130+ defer b.Close()
131+
132+ bURL, _ := url.Parse(b.URL)
133+ p := testBitbucketProvider(bURL.Host, "bioinformatics", "")
134+
135+ session := &sessions.SessionState{AccessToken: "imaginary_access_token"}
136+ email, err := p.GetEmailAddress(session)
137+ assert.Equal(t, nil, err)
138+ assert.Equal(t, "michael.bland@gsa.gov", email)
139+}
140+
141+// Note that trying to trigger the "failed building request" case is not
142+// practical, since the only way it can fail is if the URL fails to parse.
143+func TestBitbucketProviderGetEmailAddressFailedRequest(t *testing.T) {
144+ b := testBitbucketBackend("unused payload")
145+ defer b.Close()
146+
147+ bURL, _ := url.Parse(b.URL)
148+ p := testBitbucketProvider(bURL.Host, "", "")
149+
150+ // We'll trigger a request failure by using an unexpected access
151+ // token. Alternatively, we could allow the parsing of the payload as
152+ // JSON to fail.
153+ session := &sessions.SessionState{AccessToken: "unexpected_access_token"}
154+ email, err := p.GetEmailAddress(session)
155+ assert.NotEqual(t, nil, err)
156+ assert.Equal(t, "", email)
157+}
158+
159+func TestBitbucketProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) {
160+ b := testBitbucketBackend("{\"foo\": \"bar\"}")
161+ defer b.Close()
162+
163+ bURL, _ := url.Parse(b.URL)
164+ p := testBitbucketProvider(bURL.Host, "", "")
165+
166+ session := &sessions.SessionState{AccessToken: "imaginary_access_token"}
167+ email, err := p.GetEmailAddress(session)
168+ assert.Equal(t, "", email)
169+ assert.Equal(t, nil, err)
170+}
providers/providers.go+2
@@ -36,6 +36,8 @@ func New(provider string, p *ProviderData) Provider {
3636 return NewOIDCProvider(p)
3737 case "login.gov":
3838 return NewLoginGovProvider(p)
39+ case "bitbucket":
40+ return NewBitbucketProvider(p)
3941 default:
4042 return NewGoogleProvider(p)
4143 }