diff --git a/Documentation/connectors/bitbucketcloud.md b/Documentation/connectors/bitbucketcloud.md new file mode 100644 index 00000000..fbf8a589 --- /dev/null +++ b/Documentation/connectors/bitbucketcloud.md @@ -0,0 +1,32 @@ +# Authentication through Bitbucket Cloud + +## Overview + +One of the login options for dex uses the Bitbucket OAuth2 flow to identify the end user through their Bitbucket account. + +When a client redeems a refresh token through dex, dex will re-query Bitbucket to update user information in the ID Token. To do this, __dex stores a readonly Bitbucket access token in its backing datastore.__ Users that reject dex's access through Bitbucket will also revoke all dex clients which authenticated them through Bitbucket. + +## Configuration + +Register a new OAuth consumer with [Bitbucket](https://confluence.atlassian.com/bitbucket/oauth-on-bitbucket-cloud-238027431.html) ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`. + +The following is an example of a configuration for `examples/config-dev.yaml`: + +```yaml +connectors: +- type: bitbucket-cloud + # Required field for connector id. + id: bitbucket-cloud + # Required field for connector name. + name: Bitbucket Cloud + config: + # Credentials can be string literals or pulled from the environment. + clientID: $BITBUCKET_CLIENT_ID + clientSecret: $BITBUCKET_CLIENT_SECRET + redirectURI: http://127.0.0.1:5556/dex/callback + # Optional teams whitelist, communicated through the "groups" scope. + # If `teams` is omitted, all of the user's Bitbucket teams are returned when the groups scope is present. + # If `teams` is provided, this acts as a whitelist - only the user's Bitbucket teams that are in the configured `teams` below will go into the groups claim. Conversely, if the user is not in any of the configured `teams`, the user will not be authenticated. + teams: + - my-team +``` diff --git a/README.md b/README.md index 33a941ff..d6d558f8 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ Dex implements the following connectors: | [LinkedIn](Documentation/connectors/linkedin.md) | yes | no | beta | | | [Microsoft](Documentation/connectors/microsoft.md) | yes | yes | beta | | | [AuthProxy](Documentation/connectors/authproxy.md) | no | no | alpha | Authentication proxies such as Apache2 mod_auth, etc. | +| [Bitbucket Cloud](Documentation/connectors/bitbucket.md) | yes | yes | alpha | | Stable, beta, and alpha are defined as: diff --git a/connector/bitbucketcloud/bitbucketcloud.go b/connector/bitbucketcloud/bitbucketcloud.go new file mode 100644 index 00000000..27a63c46 --- /dev/null +++ b/connector/bitbucketcloud/bitbucketcloud.go @@ -0,0 +1,444 @@ +// Package bitbucketcloud provides authentication strategies using Bitbucket Cloud. +package bitbucketcloud + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/bitbucket" + + "github.com/sirupsen/logrus" + + "github.com/dexidp/dex/connector" +) + +const ( + apiURL = "https://api.bitbucket.org/2.0" + + // Bitbucket requires this scope to access '/user' API endpoints. + scopeAccount = "account" + // Bitbucket requires this scope to access '/user/emails' API endpoints. + scopeEmail = "email" + // Bitbucket requires this scope to access '/teams' API endpoints + // which are used when a client includes the 'groups' scope. + scopeTeams = "team" +) + +// Config holds configuration options for Bitbucket logins. +type Config struct { + ClientID string `json:"clientID"` + ClientSecret string `json:"clientSecret"` + RedirectURI string `json:"redirectURI"` + Teams []string `json:"teams"` +} + +// Open returns a strategy for logging in through Bitbucket. +func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector, error) { + + b := bitbucketConnector{ + redirectURI: c.RedirectURI, + teams: c.Teams, + clientID: c.ClientID, + clientSecret: c.ClientSecret, + apiURL: apiURL, + logger: logger, + } + + return &b, nil +} + +type connectorData struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + Expiry time.Time `json:"expiry"` +} + +var ( + _ connector.CallbackConnector = (*bitbucketConnector)(nil) + _ connector.RefreshConnector = (*bitbucketConnector)(nil) +) + +type bitbucketConnector struct { + redirectURI string + teams []string + clientID string + clientSecret string + logger logrus.FieldLogger + apiURL string + + // the following are used only for tests + hostName string + httpClient *http.Client +} + +// groupsRequired returns whether dex requires Bitbucket's 'team' scope. +func (b *bitbucketConnector) groupsRequired(groupScope bool) bool { + return len(b.teams) > 0 || groupScope +} + +func (b *bitbucketConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config { + bitbucketScopes := []string{scopeAccount, scopeEmail} + if b.groupsRequired(scopes.Groups) { + bitbucketScopes = append(bitbucketScopes, scopeTeams) + } + + endpoint := bitbucket.Endpoint + if b.hostName != "" { + endpoint = oauth2.Endpoint{ + AuthURL: "https://" + b.hostName + "/site/oauth2/authorize", + TokenURL: "https://" + b.hostName + "/site/oauth2/access_token", + } + } + + return &oauth2.Config{ + ClientID: b.clientID, + ClientSecret: b.clientSecret, + Endpoint: endpoint, + Scopes: bitbucketScopes, + } +} + +func (b *bitbucketConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) { + if b.redirectURI != callbackURL { + return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, b.redirectURI) + } + + return b.oauth2Config(scopes).AuthCodeURL(state), nil +} + +type oauth2Error struct { + error string + errorDescription string +} + +func (e *oauth2Error) Error() string { + if e.errorDescription == "" { + return e.error + } + return e.error + ": " + e.errorDescription +} + +func (b *bitbucketConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) { + q := r.URL.Query() + if errType := q.Get("error"); errType != "" { + return identity, &oauth2Error{errType, q.Get("error_description")} + } + + oauth2Config := b.oauth2Config(s) + + ctx := r.Context() + if b.httpClient != nil { + ctx = context.WithValue(r.Context(), oauth2.HTTPClient, b.httpClient) + } + + token, err := oauth2Config.Exchange(ctx, q.Get("code")) + if err != nil { + return identity, fmt.Errorf("bitbucket: failed to get token: %v", err) + } + + client := oauth2Config.Client(ctx, token) + + user, err := b.user(ctx, client) + if err != nil { + return identity, fmt.Errorf("bitbucket: get user: %v", err) + } + + identity = connector.Identity{ + UserID: user.UUID, + Username: user.Username, + Email: user.Email, + EmailVerified: true, + } + + if b.groupsRequired(s.Groups) { + groups, err := b.getGroups(ctx, client, s.Groups, user.Username) + if err != nil { + return identity, err + } + identity.Groups = groups + } + + if s.OfflineAccess { + data := connectorData{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + Expiry: token.Expiry, + } + connData, err := json.Marshal(data) + if err != nil { + return identity, fmt.Errorf("bitbucket: marshal connector data: %v", err) + } + identity.ConnectorData = connData + } + + return identity, nil +} + +// Refreshing tokens +// https://github.com/golang/oauth2/issues/84#issuecomment-332860871 +type tokenNotifyFunc func(*oauth2.Token) error + +// notifyRefreshTokenSource is essentially `oauth2.ReuseTokenSource` with `TokenNotifyFunc` added. +type notifyRefreshTokenSource struct { + new oauth2.TokenSource + mu sync.Mutex // guards t + t *oauth2.Token + f tokenNotifyFunc // called when token refreshed so new refresh token can be persisted +} + +// Token returns the current token if it's still valid, else will +// refresh the current token (using r.Context for HTTP client +// information) and return the new one. +func (s *notifyRefreshTokenSource) Token() (*oauth2.Token, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.t.Valid() { + return s.t, nil + } + t, err := s.new.Token() + if err != nil { + return nil, err + } + s.t = t + return t, s.f(t) +} + +func (b *bitbucketConnector) Refresh(ctx context.Context, s connector.Scopes, identity connector.Identity) (connector.Identity, error) { + if len(identity.ConnectorData) == 0 { + return identity, errors.New("bitbucket: no upstream access token found") + } + + var data connectorData + if err := json.Unmarshal(identity.ConnectorData, &data); err != nil { + return identity, fmt.Errorf("bitbucket: unmarshal access token: %v", err) + } + + tok := &oauth2.Token{ + AccessToken: data.AccessToken, + RefreshToken: data.RefreshToken, + Expiry: data.Expiry, + } + + client := oauth2.NewClient(ctx, ¬ifyRefreshTokenSource{ + new: b.oauth2Config(s).TokenSource(ctx, tok), + t: tok, + f: func(tok *oauth2.Token) error { + data := connectorData{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + Expiry: tok.Expiry, + } + connData, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("bitbucket: marshal connector data: %v", err) + } + identity.ConnectorData = connData + return nil + }, + }) + + user, err := b.user(ctx, client) + if err != nil { + return identity, fmt.Errorf("bitbucket: get user: %v", err) + } + + identity.Username = user.Username + identity.Email = user.Email + + if b.groupsRequired(s.Groups) { + groups, err := b.getGroups(ctx, client, s.Groups, user.Username) + if err != nil { + return identity, err + } + identity.Groups = groups + } + + return identity, nil +} + +// Bitbucket pagination wrapper +type pagedResponse struct { + Size int `json:"size"` + Page int `json:"page"` + PageLen int `json:"pagelen"` + Next *string `json:"next"` + Previous *string `json:"previous"` +} + +// user holds Bitbucket user information (relevant to dex) as defined by +// https://developer.atlassian.com/bitbucket/api/2/reference/resource/user +type user struct { + Username string `json:"username"` + UUID string `json:"uuid"` + Email string `json:"email"` +} + +// user queries the Bitbucket API for profile information using the provided client. +// +// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package, +// which inserts a bearer token as part of the request. +func (b *bitbucketConnector) user(ctx context.Context, client *http.Client) (user, error) { + // https://developer.atlassian.com/bitbucket/api/2/reference/resource/user + var ( + u user + err error + ) + + if err = get(ctx, client, b.apiURL+"/user", &u); err != nil { + return user{}, err + } + + if u.Email, err = b.userEmail(ctx, client); err != nil { + return user{}, err + } + + return u, nil +} + +// userEmail holds Bitbucket user email information as defined by +// https://developer.atlassian.com/bitbucket/api/2/reference/resource/user/emails +type userEmail struct { + IsPrimary bool `json:"is_primary"` + IsConfirmed bool `json:"is_confirmed"` + Email string `json:"email"` +} + +type userEmailResponse struct { + pagedResponse + Values []userEmail +} + +// userEmail returns the users primary, confirmed email +// +// The HTTP client is expected to be constructed by the golang.org/x/oauth2 package, +// which inserts a bearer token as part of the request. +func (b *bitbucketConnector) userEmail(ctx context.Context, client *http.Client) (string, error) { + apiURL := b.apiURL + "/user/emails" + for { + // https://developer.atlassian.com/bitbucket/api/2/reference/resource/user/emails + var response userEmailResponse + + if err := get(ctx, client, apiURL, &response); err != nil { + return "", err + } + + for _, email := range response.Values { + if email.IsConfirmed && email.IsPrimary { + return email.Email, nil + } + } + + if response.Next == nil { + break + } + } + + return "", errors.New("bitbucket: user has no confirmed, primary email") +} + +// getGroups retrieves Bitbucket teams a user is in, if any. +func (b *bitbucketConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) { + bitbucketTeams, err := b.userTeams(ctx, client) + if err != nil { + return nil, err + } + + if len(b.teams) > 0 { + filteredTeams := filterTeams(bitbucketTeams, b.teams) + if len(filteredTeams) == 0 { + return nil, fmt.Errorf("bitbucket: user %q is not in any of the required teams", userLogin) + } + return filteredTeams, nil + } else if groupScope { + return bitbucketTeams, nil + } + + return nil, nil +} + +// Filter the users' team memberships by 'teams' from config. +func filterTeams(userTeams, configTeams []string) []string { + teams := []string{} + teamFilter := make(map[string]struct{}) + for _, team := range configTeams { + teamFilter[team] = struct{}{} + } + for _, team := range userTeams { + if _, ok := teamFilter[team]; ok { + teams = append(teams, team) + } + } + return teams +} + +type team struct { + Name string `json:"username"` // The "username" from Bitbucket Cloud is actually the team name here +} + +type userTeamsResponse struct { + pagedResponse + Values []team +} + +func (b *bitbucketConnector) userTeams(ctx context.Context, client *http.Client) ([]string, error) { + + var teams []string + apiURL := b.apiURL + "/teams?role=member" + + for { + // https://developer.atlassian.com/bitbucket/api/2/reference/resource/teams + var response userTeamsResponse + + if err := get(ctx, client, apiURL, &response); err != nil { + return nil, fmt.Errorf("bitbucket: get user teams: %v", err) + } + + for _, team := range response.Values { + teams = append(teams, team.Name) + } + + if response.Next == nil { + break + } + } + + return teams, nil +} + +// get creates a "GET `apiURL`" request with context, sends the request using +// the client, and decodes the resulting response body into v. +// Any errors encountered when building requests, sending requests, and +// reading and decoding response data are returned. +func get(ctx context.Context, client *http.Client, apiURL string, v interface{}) error { + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return fmt.Errorf("bitbucket: new req: %v", err) + } + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("bitbucket: get URL %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("bitbucket: read body: %s: %v", resp.Status, err) + } + return fmt.Errorf("%s: %s", resp.Status, body) + } + + if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + return fmt.Errorf("bitbucket: failed to decode response: %v", err) + } + + return nil +} diff --git a/connector/bitbucketcloud/bitbucketcloud_test.go b/connector/bitbucketcloud/bitbucketcloud_test.go new file mode 100644 index 00000000..b9f4ba08 --- /dev/null +++ b/connector/bitbucketcloud/bitbucketcloud_test.go @@ -0,0 +1,124 @@ +package bitbucketcloud + +import ( + "context" + "crypto/tls" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "github.com/dexidp/dex/connector" +) + +func TestUserGroups(t *testing.T) { + + teamsResponse := userTeamsResponse{ + pagedResponse: pagedResponse{ + Size: 3, + Page: 1, + PageLen: 10, + }, + Values: []team{ + {Name: "team-1"}, + {Name: "team-2"}, + {Name: "team-3"}, + }, + } + + s := newTestServer(map[string]interface{}{ + "/teams?role=member": teamsResponse, + }) + + connector := bitbucketConnector{apiURL: s.URL} + groups, err := connector.userTeams(context.Background(), newClient()) + + expectNil(t, err) + expectEquals(t, groups, []string{ + "team-1", + "team-2", + "team-3", + }) + + s.Close() +} + +func TestUserWithoutTeams(t *testing.T) { + + s := newTestServer(map[string]interface{}{ + "/teams?role=member": userTeamsResponse{}, + }) + + connector := bitbucketConnector{apiURL: s.URL} + groups, err := connector.userTeams(context.Background(), newClient()) + + expectNil(t, err) + expectEquals(t, len(groups), 0) + + s.Close() +} + +func TestUsernameIncludedInFederatedIdentity(t *testing.T) { + + s := newTestServer(map[string]interface{}{ + "/user": user{Username: "some-login"}, + "/user/emails": userEmailResponse{ + pagedResponse: pagedResponse{ + Size: 1, + Page: 1, + PageLen: 10, + }, + Values: []userEmail{{ + Email: "some@email.com", + IsConfirmed: true, + IsPrimary: true, + }}, + }, + "/site/oauth2/access_token": map[string]interface{}{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9", + "expires_in": "30", + }, + }) + + hostURL, err := url.Parse(s.URL) + expectNil(t, err) + + req, err := http.NewRequest("GET", hostURL.String(), nil) + expectNil(t, err) + + bitbucketConnector := bitbucketConnector{apiURL: s.URL, hostName: hostURL.Host, httpClient: newClient()} + identity, err := bitbucketConnector.HandleCallback(connector.Scopes{}, req) + + expectNil(t, err) + expectEquals(t, identity.Username, "some-login") + + s.Close() +} + +func newTestServer(responses map[string]interface{}) *httptest.Server { + return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + json.NewEncoder(w).Encode(responses[r.URL.String()]) + })) +} + +func newClient() *http.Client { + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + return &http.Client{Transport: tr} +} + +func expectNil(t *testing.T, a interface{}) { + if a != nil { + t.Fatalf("Expected %+v to equal nil", a) + } +} + +func expectEquals(t *testing.T, a interface{}, b interface{}) { + if !reflect.DeepEqual(a, b) { + t.Fatalf("Expected %+v to equal %+v", a, b) + } +} diff --git a/glide.lock b/glide.lock index 82e1f2a8..d646231b 100644 --- a/glide.lock +++ b/glide.lock @@ -1,5 +1,5 @@ hash: 12d0ad2fc0df4ab221e45c1ba7821708b908033c82741e250cc46dcd445b67eb -updated: 2018-09-18T23:51:30.787348994+02:00 +updated: 2018-09-30T14:07:57.901347233-04:00 imports: - name: github.com/beevik/etree version: 4cd0dd976db869f817248477718071a28e978df0 @@ -124,6 +124,7 @@ imports: - name: golang.org/x/oauth2 version: 08c8d727d2392d18286f9f88ad775ad98f09ab33 subpackages: + - bitbucket - github - internal - name: golang.org/x/sys diff --git a/server/server.go b/server/server.go index d96f562d..adf872eb 100644 --- a/server/server.go +++ b/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/dexidp/dex/connector" "github.com/dexidp/dex/connector/authproxy" + "github.com/dexidp/dex/connector/bitbucketcloud" "github.com/dexidp/dex/connector/github" "github.com/dexidp/dex/connector/gitlab" "github.com/dexidp/dex/connector/ldap" @@ -429,16 +430,17 @@ type ConnectorConfig interface { // ConnectorsConfig variable provides an easy way to return a config struct // depending on the connector type. var ConnectorsConfig = map[string]func() ConnectorConfig{ - "mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) }, - "mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) }, - "ldap": func() ConnectorConfig { return new(ldap.Config) }, - "github": func() ConnectorConfig { return new(github.Config) }, - "gitlab": func() ConnectorConfig { return new(gitlab.Config) }, - "oidc": func() ConnectorConfig { return new(oidc.Config) }, - "saml": func() ConnectorConfig { return new(saml.Config) }, - "authproxy": func() ConnectorConfig { return new(authproxy.Config) }, - "linkedin": func() ConnectorConfig { return new(linkedin.Config) }, - "microsoft": func() ConnectorConfig { return new(microsoft.Config) }, + "mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) }, + "mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) }, + "ldap": func() ConnectorConfig { return new(ldap.Config) }, + "github": func() ConnectorConfig { return new(github.Config) }, + "gitlab": func() ConnectorConfig { return new(gitlab.Config) }, + "oidc": func() ConnectorConfig { return new(oidc.Config) }, + "saml": func() ConnectorConfig { return new(saml.Config) }, + "authproxy": func() ConnectorConfig { return new(authproxy.Config) }, + "linkedin": func() ConnectorConfig { return new(linkedin.Config) }, + "microsoft": func() ConnectorConfig { return new(microsoft.Config) }, + "bitbucket-cloud": func() ConnectorConfig { return new(bitbucketcloud.Config) }, // Keep around for backwards compatibility. "samlExperimental": func() ConnectorConfig { return new(saml.Config) }, } diff --git a/vendor/golang.org/x/oauth2/bitbucket/bitbucket.go b/vendor/golang.org/x/oauth2/bitbucket/bitbucket.go new file mode 100644 index 00000000..44af1f1a --- /dev/null +++ b/vendor/golang.org/x/oauth2/bitbucket/bitbucket.go @@ -0,0 +1,16 @@ +// Copyright 2015 The oauth2 Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package bitbucket provides constants for using OAuth2 to access Bitbucket. +package bitbucket + +import ( + "golang.org/x/oauth2" +) + +// Endpoint is Bitbucket's OAuth 2.0 endpoint. +var Endpoint = oauth2.Endpoint{ + AuthURL: "https://bitbucket.org/site/oauth2/authorize", + TokenURL: "https://bitbucket.org/site/oauth2/access_token", +}