Repository: pusher/oauth2_proxy (go)

Issue:
BitBucket support
I work for an org that uses BitBucket Cloud for all of our repos. It'd be nice if oauth2_proxy could be used w/ BitBucket.

## Expected Behavior

It'd be nice if oauth2_proxy has a BitBucket provider.

## Current Behavior

oauth2_proxy can't be used with BitBucket right now. BitBucket supports Oauth2, but not OpenID connect, so a new provider would be needed.

## Possible Solution

Add bitbucket as a provider? I'd be willing to spend some time writing a PR for this, but it may take me awhile and I lack confidence in my skills.

## Steps to Reproduce (for bugs)

N/A

## Context

The org I work for uses BitBucket cloud for all of our repos. I'd like to deploy various internal tools for developers to access and authorize them using their BitBucket credentials.

## Your Environment



Pull request:
Add Bitbucket provider.
##  Description

Adds Bitbucket to the providers, includes tests.

## Motivation and Context

Fix #40 

## How Has This Been Tested?

This PR is based on a fork of the original Bitly project. We've been using that fork in PROD for several months by now.

## Checklist:

- [X] My change requires a change to the documentation or CHANGELOG.
- [X] I have updated the documentation/CHANGELOG accordingly.
- [X] I have created a feature (non-master) branch for my PR.


Patch:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index a6f7701e..c7f2960e 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -10,3 +10,7 @@
 # or the public devops channel at https://chat.18f.gov/).
 providers/logingov.go @timothy-spencer
 providers/logingov_test.go @timothy-spencer
+
+# Bitbucket provider
+providers/bitbucket.go @aledeganopix4d
+providers/bitbucket_test.go @aledeganopix4d
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fec05c66..526ecdf5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -96,6 +96,10 @@
 - [#198](https://github.com/pusher/oauth2_proxy/pull/198) Switch from gometalinter to golangci-lint (@steakunderscore)
 - [#159](https://github.com/pusher/oauth2_proxy/pull/159) Add option to skip the OIDC provider verified email check: `--insecure-oidc-allow-unverified-email`
 - [#210](https://github.com/pusher/oauth2_proxy/pull/210) Update base image from Alpine 3.9 to 3.10 (@steakunderscore)
+- [#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)
+  - Implement flags to enable Bitbucket authentication:
+    - `-bitbucket-repository` Restrict authorization to users that can access this repository
+    - `-bitbucket-team` Restrict authorization to users that are part of this Bitbucket team
 - [#211](https://github.com/pusher/oauth2_proxy/pull/211) Switch from dep to go modules (@steakunderscore)
 - [#145](https://github.com/pusher/oauth2_proxy/pull/145) Add support for OIDC UserInfo endpoint email verification (@rtluckie)
 
diff --git a/main.go b/main.go
index 872990a9..a9f1e4a0 100644
--- a/main.go
+++ b/main.go
@@ -56,6 +56,8 @@ func main() {
 	flagSet.Var(&emailDomains, "email-domain", "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
 	flagSet.Var(&whitelistDomains, "whitelist-domain", "allowed domains for redirection after authentication. Prefix domain with a . to allow subdomains (eg .example.com)")
 	flagSet.String("azure-tenant", "common", "go to a tenant-specific or common (tenant-independent) endpoint.")
+	flagSet.String("bitbucket-team", "", "restrict logins to members of this team")
+	flagSet.String("bitbucket-repository", "", "restrict logins to user with access to this repository")
 	flagSet.String("github-org", "", "restrict logins to members of this organisation")
 	flagSet.String("github-team", "", "restrict logins to members of this team")
 	flagSet.String("gitlab-group", "", "restrict logins to members of this group")
diff --git a/options.go b/options.go
index 03f5ff38..706f6d5e 100644
--- a/options.go
+++ b/options.go
@@ -42,6 +42,8 @@ type Options struct {
 
 	AuthenticatedEmailsFile  string   `flag:"authenticated-emails-file" cfg:"authenticated_emails_file" env:"OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE"`
 	AzureTenant              string   `flag:"azure-tenant" cfg:"azure_tenant" env:"OAUTH2_PROXY_AZURE_TENANT"`
+	BitbucketTeam            string   `flag:"bitbucket-team" cfg:"bitbucket_team" env:"OAUTH2_PROXY_BITBUCKET_TEAM"`
+	BitbucketRepository      string   `flag:"bitbucket-repository" cfg:"bitbucket_repository" env:"OAUTH2_PROXY_BITBUCKET_REPOSITORY"`
 	EmailDomains             []string `flag:"email-domain" cfg:"email_domains" env:"OAUTH2_PROXY_EMAIL_DOMAINS"`
 	WhitelistDomains         []string `flag:"whitelist-domain" cfg:"whitelist_domains" env:"OAUTH2_PROXY_WHITELIST_DOMAINS"`
 	GitHubOrg                string   `flag:"github-org" cfg:"github_org" env:"OAUTH2_PROXY_GITHUB_ORG"`
@@ -405,6 +407,9 @@ func parseProviderInfo(o *Options, msgs []string) []string {
 				p.SetGroupRestriction(o.GoogleGroups, o.GoogleAdminEmail, file)
 			}
 		}
+	case *providers.BitbucketProvider:
+		p.SetTeam(o.BitbucketTeam)
+		p.SetRepository(o.BitbucketRepository)
 	case *providers.OIDCProvider:
 		p.AllowUnverifiedEmail = o.InsecureOIDCAllowUnverifiedEmail
 		if o.oidcVerifier == nil {
diff --git a/providers/bitbucket.go b/providers/bitbucket.go
new file mode 100644
index 00000000..63c1d0fa
--- /dev/null
+++ b/providers/bitbucket.go
@@ -0,0 +1,163 @@
+package providers
+
+import (
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/pusher/oauth2_proxy/pkg/apis/sessions"
+	"github.com/pusher/oauth2_proxy/pkg/logger"
+	"github.com/pusher/oauth2_proxy/pkg/requests"
+)
+
+// BitbucketProvider represents an Bitbucket based Identity Provider
+type BitbucketProvider struct {
+	*ProviderData
+	Team       string
+	Repository string
+}
+
+// NewBitbucketProvider initiates a new BitbucketProvider
+func NewBitbucketProvider(p *ProviderData) *BitbucketProvider {
+	p.ProviderName = "Bitbucket"
+	if p.LoginURL == nil || p.LoginURL.String() == "" {
+		p.LoginURL = &url.URL{
+			Scheme: "https",
+			Host:   "bitbucket.org",
+			Path:   "/site/oauth2/authorize",
+		}
+	}
+	if p.RedeemURL == nil || p.RedeemURL.String() == "" {
+		p.RedeemURL = &url.URL{
+			Scheme: "https",
+			Host:   "bitbucket.org",
+			Path:   "/site/oauth2/access_token",
+		}
+	}
+	if p.ValidateURL == nil || p.ValidateURL.String() == "" {
+		p.ValidateURL = &url.URL{
+			Scheme: "https",
+			Host:   "api.bitbucket.org",
+			Path:   "/2.0/user/emails",
+		}
+	}
+	if p.Scope == "" {
+		p.Scope = "email"
+	}
+	return &BitbucketProvider{ProviderData: p}
+}
+
+// SetTeam defines the Bitbucket team the user must be part of
+func (p *BitbucketProvider) SetTeam(team string) {
+	p.Team = team
+	if !strings.Contains(p.Scope, "team") {
+		p.Scope += " team"
+	}
+}
+
+// SetRepository defines the repository the user must have access to
+func (p *BitbucketProvider) SetRepository(repository string) {
+	p.Repository = repository
+	if !strings.Contains(p.Scope, "repository") {
+		p.Scope += " repository"
+	}
+}
+
+// GetEmailAddress returns the email of the authenticated user
+func (p *BitbucketProvider) GetEmailAddress(s *sessions.SessionState) (string, error) {
+
+	var emails struct {
+		Values []struct {
+			Email   string `json:"email"`
+			Primary bool   `json:"is_primary"`
+		}
+	}
+	var teams struct {
+		Values []struct {
+			Name string `json:"username"`
+		}
+	}
+	var repositories struct {
+		Values []struct {
+			FullName string `json:"full_name"`
+		}
+	}
+	req, err := http.NewRequest("GET",
+		p.ValidateURL.String()+"?access_token="+s.AccessToken, nil)
+	if err != nil {
+		logger.Printf("failed building request %s", err)
+		return "", err
+	}
+	err = requests.RequestJSON(req, &emails)
+	if err != nil {
+		logger.Printf("failed making request %s", err)
+		return "", err
+	}
+
+	if p.Team != "" {
+		teamURL := &url.URL{}
+		*teamURL = *p.ValidateURL
+		teamURL.Path = "/2.0/teams"
+		req, err = http.NewRequest("GET",
+			teamURL.String()+"?role=member&access_token="+s.AccessToken, nil)
+		if err != nil {
+			logger.Printf("failed building request %s", err)
+			return "", err
+		}
+		err = requests.RequestJSON(req, &teams)
+		if err != nil {
+			logger.Printf("failed requesting teams membership %s", err)
+			return "", err
+		}
+		var found = false
+		for _, team := range teams.Values {
+			if p.Team == team.Name {
+				found = true
+				break
+			}
+		}
+		if found != true {
+			logger.Print("team membership test failed, access denied")
+			return "", nil
+		}
+	}
+
+	if p.Repository != "" {
+		repositoriesURL := &url.URL{}
+		*repositoriesURL = *p.ValidateURL
+		repositoriesURL.Path = "/2.0/repositories/" + strings.Split(p.Repository, "/")[0]
+		req, err = http.NewRequest("GET",
+			repositoriesURL.String()+"?role=contributor"+
+				"&q=full_name="+url.QueryEscape("\""+p.Repository+"\"")+
+				"&access_token="+s.AccessToken,
+			nil)
+		if err != nil {
+			logger.Printf("failed building request %s", err)
+			return "", err
+		}
+		err = requests.RequestJSON(req, &repositories)
+		if err != nil {
+			logger.Printf("failed checking repository access %s", err)
+			return "", err
+		}
+		var found = false
+		for _, repository := range repositories.Values {
+			if p.Repository == repository.FullName {
+				found = true
+				break
+			}
+		}
+		if found != true {
+			logger.Print("repository access test failed, access denied")
+			return "", nil
+		}
+	}
+
+	for _, email := range emails.Values {
+		if email.Primary {
+			return email.Email, nil
+		}
+	}
+
+	return "", nil
+}
diff --git a/providers/providers.go b/providers/providers.go
index baf723d9..276fab62 100644
--- a/providers/providers.go
+++ b/providers/providers.go
@@ -36,6 +36,8 @@ func New(provider string, p *ProviderData) Provider {
 		return NewOIDCProvider(p)
 	case "login.gov":
 		return NewLoginGovProvider(p)
+	case "bitbucket":
+		return NewBitbucketProvider(p)
 	default:
 		return NewGoogleProvider(p)
 	}


Test patch:
diff --git a/providers/bitbucket_test.go b/providers/bitbucket_test.go
new file mode 100644
index 00000000..585603d0
--- /dev/null
+++ b/providers/bitbucket_test.go
@@ -0,0 +1,170 @@
+package providers
+
+import (
+	"log"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/pusher/oauth2_proxy/pkg/apis/sessions"
+)
+
+func testBitbucketProvider(hostname, team string, repository string) *BitbucketProvider {
+	p := NewBitbucketProvider(
+		&ProviderData{
+			ProviderName: "",
+			LoginURL:     &url.URL{},
+			RedeemURL:    &url.URL{},
+			ProfileURL:   &url.URL{},
+			ValidateURL:  &url.URL{},
+			Scope:        ""})
+
+	if team != "" {
+		p.SetTeam(team)
+	}
+
+	if repository != "" {
+		p.SetRepository(repository)
+	}
+
+	if hostname != "" {
+		updateURL(p.Data().LoginURL, hostname)
+		updateURL(p.Data().RedeemURL, hostname)
+		updateURL(p.Data().ProfileURL, hostname)
+		updateURL(p.Data().ValidateURL, hostname)
+	}
+	return p
+}
+
+func testBitbucketBackend(payload string) *httptest.Server {
+	paths := map[string]bool{
+		"/2.0/user/emails": true,
+		"/2.0/teams":       true,
+	}
+
+	return httptest.NewServer(http.HandlerFunc(
+		func(w http.ResponseWriter, r *http.Request) {
+			url := r.URL
+			if !paths[url.Path] {
+				log.Printf("%s not in %+v\n", url.Path, paths)
+				w.WriteHeader(404)
+			} else if r.URL.Query().Get("access_token") != "imaginary_access_token" {
+				w.WriteHeader(403)
+			} else {
+				w.WriteHeader(200)
+				w.Write([]byte(payload))
+			}
+		}))
+}
+
+func TestBitbucketProviderDefaults(t *testing.T) {
+	p := testBitbucketProvider("", "", "")
+	assert.NotEqual(t, nil, p)
+	assert.Equal(t, "Bitbucket", p.Data().ProviderName)
+	assert.Equal(t, "https://bitbucket.org/site/oauth2/authorize",
+		p.Data().LoginURL.String())
+	assert.Equal(t, "https://bitbucket.org/site/oauth2/access_token",
+		p.Data().RedeemURL.String())
+	assert.Equal(t, "https://api.bitbucket.org/2.0/user/emails",
+		p.Data().ValidateURL.String())
+	assert.Equal(t, "email", p.Data().Scope)
+}
+
+func TestBitbucketProviderScopeAdjustForTeam(t *testing.T) {
+	p := testBitbucketProvider("", "test-team", "")
+	assert.NotEqual(t, nil, p)
+	assert.Equal(t, "email team", p.Data().Scope)
+}
+
+func TestBitbucketProviderScopeAdjustForRepository(t *testing.T) {
+	p := testBitbucketProvider("", "", "rest-repo")
+	assert.NotEqual(t, nil, p)
+	assert.Equal(t, "email repository", p.Data().Scope)
+}
+
+func TestBitbucketProviderOverrides(t *testing.T) {
+	p := NewBitbucketProvider(
+		&ProviderData{
+			LoginURL: &url.URL{
+				Scheme: "https",
+				Host:   "example.com",
+				Path:   "/oauth/auth"},
+			RedeemURL: &url.URL{
+				Scheme: "https",
+				Host:   "example.com",
+				Path:   "/oauth/token"},
+			ValidateURL: &url.URL{
+				Scheme: "https",
+				Host:   "example.com",
+				Path:   "/api/v3/user"},
+			Scope: "profile"})
+	assert.NotEqual(t, nil, p)
+	assert.Equal(t, "Bitbucket", p.Data().ProviderName)
+	assert.Equal(t, "https://example.com/oauth/auth",
+		p.Data().LoginURL.String())
+	assert.Equal(t, "https://example.com/oauth/token",
+		p.Data().RedeemURL.String())
+	assert.Equal(t, "https://example.com/api/v3/user",
+		p.Data().ValidateURL.String())
+	assert.Equal(t, "profile", p.Data().Scope)
+}
+
+func TestBitbucketProviderGetEmailAddress(t *testing.T) {
+	b := testBitbucketBackend("{\"values\": [ { \"email\": \"michael.bland@gsa.gov\", \"is_primary\": true } ] }")
+	defer b.Close()
+
+	bURL, _ := url.Parse(b.URL)
+	p := testBitbucketProvider(bURL.Host, "", "")
+
+	session := &sessions.SessionState{AccessToken: "imaginary_access_token"}
+	email, err := p.GetEmailAddress(session)
+	assert.Equal(t, nil, err)
+	assert.Equal(t, "michael.bland@gsa.gov", email)
+}
+
+func TestBitbucketProviderGetEmailAddressAndGroup(t *testing.T) {
+	b := testBitbucketBackend("{\"values\": [ { \"email\": \"michael.bland@gsa.gov\", \"is_primary\": true, \"username\": \"bioinformatics\" } ] }")
+	defer b.Close()
+
+	bURL, _ := url.Parse(b.URL)
+	p := testBitbucketProvider(bURL.Host, "bioinformatics", "")
+
+	session := &sessions.SessionState{AccessToken: "imaginary_access_token"}
+	email, err := p.GetEmailAddress(session)
+	assert.Equal(t, nil, err)
+	assert.Equal(t, "michael.bland@gsa.gov", email)
+}
+
+// Note that trying to trigger the "failed building request" case is not
+// practical, since the only way it can fail is if the URL fails to parse.
+func TestBitbucketProviderGetEmailAddressFailedRequest(t *testing.T) {
+	b := testBitbucketBackend("unused payload")
+	defer b.Close()
+
+	bURL, _ := url.Parse(b.URL)
+	p := testBitbucketProvider(bURL.Host, "", "")
+
+	// We'll trigger a request failure by using an unexpected access
+	// token. Alternatively, we could allow the parsing of the payload as
+	// JSON to fail.
+	session := &sessions.SessionState{AccessToken: "unexpected_access_token"}
+	email, err := p.GetEmailAddress(session)
+	assert.NotEqual(t, nil, err)
+	assert.Equal(t, "", email)
+}
+
+func TestBitbucketProviderGetEmailAddressEmailNotPresentInPayload(t *testing.T) {
+	b := testBitbucketBackend("{\"foo\": \"bar\"}")
+	defer b.Close()
+
+	bURL, _ := url.Parse(b.URL)
+	p := testBitbucketProvider(bURL.Host, "", "")
+
+	session := &sessions.SessionState{AccessToken: "imaginary_access_token"}
+	email, err := p.GetEmailAddress(session)
+	assert.Equal(t, "", email)
+	assert.Equal(t, nil, err)
+}