diff --git a/README.md b/README.md index d4718fb2f..8c89420c2 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ Valid providers are : * [GitLab](#gitlab-auth-provider) * [LinkedIn](#linkedin-auth-provider) * [Discord](#discord-auth-provider) +* [Bitbucket](#bitbucket-auth-provider) The provider can be selected using the `provider` configuration value. @@ -237,6 +238,17 @@ In this case, you can set the `-skip-oidc-discovery` option, and supply those re 1. Create a new Discord Application from 2. Under OAuth2, Add Redirect to `https://internal.yourcompany.com/oauth2/callback` +### Bitbucket Auth Provider + +The [Bitbucket](https://bitbucket.org) provider. + +For Bitbucket, follow the [registration steps to create an OAuth client](https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html#OAuthonBitbucketCloud-Createaconsumer). + +The Bitbucket auth provider supports one additional parameter to restrict +authentication to members of a given Bitbucket team. Restricting by team is +normally accompanied with `--email-domain=*` + + -bitbucket-team="": restrict logins to members of this team ## Email Authentication diff --git a/main.go b/main.go index f68992fc8..d9370b806 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ func mainFlagSet() *flag.FlagSet { 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 domain for redirection after authentication, leading '.' allows subdomains (may be given multiple times)") 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("github-org", "", "restrict logins to members of this organisation") flagSet.String("github-team", "", "restrict logins to members of this team (slug) (may be given multiple times)") flagSet.Var(&gitlabGroups, "gitlab-group", "restrict logins to members of this group (full path) (may be given multiple times)") diff --git a/options.go b/options.go index c488d0fc0..d16bbd71f 100644 --- a/options.go +++ b/options.go @@ -30,6 +30,7 @@ type Options struct { AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"` AzureTenant string `flag:"azure-tenant" cfg:"azure_tenant"` + BitbucketTeam string `flag:"bitbucket-team" cfg:"bitbucket_team"` EmailDomains []string `flag:"email-domain" cfg:"email_domains"` WhitelistDomains []string `flag:"whitelist-domain" cfg:"whitelist_domains" env:"OAUTH2_PROXY_WHITELIST_DOMAINS"` GitHubOrg string `flag:"github-org" cfg:"github_org"` @@ -243,6 +244,8 @@ func parseProviderInfo(o *Options, msgs []string) []string { switch p := o.provider.(type) { case *providers.AzureProvider: p.Configure(o.AzureTenant) + case *providers.BitbucketProvider: + p.SetTeam(o.BitbucketTeam) case *providers.GitHubProvider: p.SetOrgTeam(o.GitHubOrg, o.GitHubTeam) case *providers.GitLabProvider: diff --git a/providers/bitbucket.go b/providers/bitbucket.go new file mode 100644 index 000000000..72e86de57 --- /dev/null +++ b/providers/bitbucket.go @@ -0,0 +1,123 @@ +package providers + +import ( + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/bitly/oauth2_proxy/api" +) + +type BitbucketProvider struct { + *ProviderData + Team string +} + +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 = "account team" + } + return &BitbucketProvider{ProviderData: p} +} + +func (p *BitbucketProvider) SetTeam(team string) { + p.Team = team +} + +func debug(data []byte, err error) { + if err == nil { + fmt.Printf("%s\n\n", data) + } else { + log.Fatalf("%s\n\n", err) + } +} + +func (p *BitbucketProvider) GetEmailAddress(s *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"` + } + } + req, err := http.NewRequest("GET", + p.ValidateURL.String()+"?access_token="+s.AccessToken, nil) + if err != nil { + log.Printf("failed building request %s", err) + return "", err + } + err = api.RequestJson(req, &emails) + if err != nil { + log.Printf("failed making request %s", err) + debug(httputil.DumpRequestOut(req, true)) + return "", err + } + + if p.Team != "" { + log.Printf("Filtering against membership in team %s\n", 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 { + log.Printf("failed building request %s", err) + return "", err + } + err = api.RequestJson(req, &teams) + if err != nil { + log.Printf("failed requesting teams membership %s", err) + debug(httputil.DumpRequestOut(req, true)) + return "", err + } + var found = false + log.Printf("%+v\n", teams) + for _, team := range teams.Values { + if p.Team == team.Name { + found = true + break + } + } + if found != true { + log.Printf("team membership test failed, access denied") + return "", nil + } + } + + for _, email := range emails.Values { + if email.Primary { + return email.Email, nil + } + } + + return "", nil +} diff --git a/providers/bitbucket_test.go b/providers/bitbucket_test.go new file mode 100644 index 000000000..702062288 --- /dev/null +++ b/providers/bitbucket_test.go @@ -0,0 +1,152 @@ +package providers + +import ( + "log" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/bmizerany/assert" +) + +func testBitbucketProvider(hostname, team 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 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, "account team", 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() + + b_url, _ := url.Parse(b.URL) + p := testBitbucketProvider(b_url.Host, "") + + session := &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() + + b_url, _ := url.Parse(b.URL) + p := testBitbucketProvider(b_url.Host, "bioinformatics") + + session := &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() + + b_url, _ := url.Parse(b.URL) + p := testBitbucketProvider(b_url.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 := &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() + + b_url, _ := url.Parse(b.URL) + p := testBitbucketProvider(b_url.Host, "") + + session := &SessionState{AccessToken: "imaginary_access_token"} + email, err := p.GetEmailAddress(session) + assert.Equal(t, "", email) + assert.Equal(t, nil, err) +} diff --git a/providers/providers.go b/providers/providers.go index 194f0edc9..1d1ad8f46 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -33,6 +33,8 @@ func New(provider string, p *ProviderData) Provider { return NewOIDCProvider(p) case "discord": return NewDiscordProvider(p) + case "bitbucket": + return NewBitbucketProvider(p) default: return NewGoogleProvider(p) }