Add Bitbucket connector
This commit is contained in:
		
							
								
								
									
										30
									
								
								Documentation/connectors/bitbucket.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								Documentation/connectors/bitbucket.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| # 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 | ||||
|   # Required field for connector id. | ||||
|   id: bitbucket | ||||
|   # Required field for connector name. | ||||
|   name: Bitbucket | ||||
|   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, communicated through the "groups" scope. | ||||
|     teams: | ||||
|     - my-team | ||||
| ``` | ||||
| @@ -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: | ||||
|  | ||||
|   | ||||
							
								
								
									
										446
									
								
								connector/bitbucket/bitbucket.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										446
									
								
								connector/bitbucket/bitbucket.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,446 @@ | ||||
| // Package bitbucket provides authentication strategies using Bitbucket. | ||||
| package bitbucket | ||||
|  | ||||
| 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 defaults to "https://api.bitbucket.org/2.0" | ||||
| 	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"` | ||||
| 	DisplayName string `json:"display_name"` | ||||
| 	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 u, err | ||||
| 	} | ||||
|  | ||||
| 	if u.Email, err = b.userEmail(ctx, client); err != nil { | ||||
| 		return u, 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"` | ||||
| 	Type        string `json:"type"` | ||||
| 	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 not in required teams", userLogin) | ||||
| 		} else { | ||||
| 			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) (teams []string) { | ||||
| 	teamFilter := make(map[string]struct{}) | ||||
| 	for _, team := range configTeams { | ||||
| 		if _, ok := teamFilter[team]; !ok { | ||||
| 			teamFilter[team] = struct{}{} | ||||
| 		} | ||||
| 	} | ||||
| 	for _, team := range userTeams { | ||||
| 		if _, ok := teamFilter[team]; ok { | ||||
| 			teams = append(teams, team) | ||||
| 		} | ||||
| 	} | ||||
| 	return | ||||
| } | ||||
|  | ||||
| type team struct { | ||||
| 	Username string `json:"username"` // Username is actually the team name | ||||
| } | ||||
|  | ||||
| type userTeamsResponse struct { | ||||
| 	pagedResponse | ||||
| 	Values []team | ||||
| } | ||||
|  | ||||
| func (b *bitbucketConnector) userTeams(ctx context.Context, client *http.Client) ([]string, error) { | ||||
| 	apiURL, teams := b.apiURL+"/teams?role=member", []string{} | ||||
| 	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.Username) | ||||
| 		} | ||||
|  | ||||
| 		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: %v", err) | ||||
| 		} | ||||
| 		return fmt.Errorf("%s: %s", resp.Status, body) | ||||
| 	} | ||||
|  | ||||
| 	if err := json.NewDecoder(resp.Body).Decode(v); err != nil { | ||||
| 		return fmt.Errorf("failed to decode response: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										124
									
								
								connector/bitbucket/bitbucket_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								connector/bitbucket/bitbucket_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,124 @@ | ||||
| package bitbucket | ||||
|  | ||||
| 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{ | ||||
| 			{Username: "team-1"}, | ||||
| 			{Username: "team-2"}, | ||||
| 			{Username: "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.Errorf("Expected %+v to equal nil", a) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func expectEquals(t *testing.T, a interface{}, b interface{}) { | ||||
| 	if !reflect.DeepEqual(a, b) { | ||||
| 		t.Errorf("Expected %+v to equal %+v", a, b) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										3
									
								
								glide.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								glide.lock
									
									
									
										generated
									
									
									
								
							| @@ -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 | ||||
|   | ||||
| @@ -24,6 +24,7 @@ import ( | ||||
|  | ||||
| 	"github.com/dexidp/dex/connector" | ||||
| 	"github.com/dexidp/dex/connector/authproxy" | ||||
| 	"github.com/dexidp/dex/connector/bitbucket" | ||||
| 	"github.com/dexidp/dex/connector/github" | ||||
| 	"github.com/dexidp/dex/connector/gitlab" | ||||
| 	"github.com/dexidp/dex/connector/ldap" | ||||
| @@ -439,6 +440,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ | ||||
| 	"authproxy":    func() ConnectorConfig { return new(authproxy.Config) }, | ||||
| 	"linkedin":     func() ConnectorConfig { return new(linkedin.Config) }, | ||||
| 	"microsoft":    func() ConnectorConfig { return new(microsoft.Config) }, | ||||
| 	"bitbucket":    func() ConnectorConfig { return new(bitbucket.Config) }, | ||||
| 	// Keep around for backwards compatibility. | ||||
| 	"samlExperimental": func() ConnectorConfig { return new(saml.Config) }, | ||||
| } | ||||
|   | ||||
							
								
								
									
										16
									
								
								vendor/golang.org/x/oauth2/bitbucket/bitbucket.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								vendor/golang.org/x/oauth2/bitbucket/bitbucket.go
									
									
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -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", | ||||
| } | ||||
		Reference in New Issue
	
	Block a user