Merge pull request #1307 from edtan/upstream-add-bitbucket-connector
Add Bitbucket connector
This commit is contained in:
		
							
								
								
									
										32
									
								
								Documentation/connectors/bitbucketcloud.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								Documentation/connectors/bitbucketcloud.md
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | ``` | ||||||
| @@ -73,6 +73,7 @@ Dex implements the following connectors: | |||||||
| | [LinkedIn](Documentation/connectors/linkedin.md) | yes | no | beta | | | | [LinkedIn](Documentation/connectors/linkedin.md) | yes | no | beta | | | ||||||
| | [Microsoft](Documentation/connectors/microsoft.md) | yes | yes | 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. | | | [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: | Stable, beta, and alpha are defined as: | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										444
									
								
								connector/bitbucketcloud/bitbucketcloud.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										444
									
								
								connector/bitbucketcloud/bitbucketcloud.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  | } | ||||||
							
								
								
									
										124
									
								
								connector/bitbucketcloud/bitbucketcloud_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								connector/bitbucketcloud/bitbucketcloud_test.go
									
									
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  | 	} | ||||||
|  | } | ||||||
							
								
								
									
										3
									
								
								glide.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								glide.lock
									
									
									
										generated
									
									
									
								
							| @@ -1,5 +1,5 @@ | |||||||
| hash: 12d0ad2fc0df4ab221e45c1ba7821708b908033c82741e250cc46dcd445b67eb | hash: 12d0ad2fc0df4ab221e45c1ba7821708b908033c82741e250cc46dcd445b67eb | ||||||
| updated: 2018-09-18T23:51:30.787348994+02:00 | updated: 2018-09-30T14:07:57.901347233-04:00 | ||||||
| imports: | imports: | ||||||
| - name: github.com/beevik/etree | - name: github.com/beevik/etree | ||||||
|   version: 4cd0dd976db869f817248477718071a28e978df0 |   version: 4cd0dd976db869f817248477718071a28e978df0 | ||||||
| @@ -124,6 +124,7 @@ imports: | |||||||
| - name: golang.org/x/oauth2 | - name: golang.org/x/oauth2 | ||||||
|   version: 08c8d727d2392d18286f9f88ad775ad98f09ab33 |   version: 08c8d727d2392d18286f9f88ad775ad98f09ab33 | ||||||
|   subpackages: |   subpackages: | ||||||
|  |   - bitbucket | ||||||
|   - github |   - github | ||||||
|   - internal |   - internal | ||||||
| - name: golang.org/x/sys | - name: golang.org/x/sys | ||||||
|   | |||||||
| @@ -24,6 +24,7 @@ import ( | |||||||
|  |  | ||||||
| 	"github.com/dexidp/dex/connector" | 	"github.com/dexidp/dex/connector" | ||||||
| 	"github.com/dexidp/dex/connector/authproxy" | 	"github.com/dexidp/dex/connector/authproxy" | ||||||
|  | 	"github.com/dexidp/dex/connector/bitbucketcloud" | ||||||
| 	"github.com/dexidp/dex/connector/github" | 	"github.com/dexidp/dex/connector/github" | ||||||
| 	"github.com/dexidp/dex/connector/gitlab" | 	"github.com/dexidp/dex/connector/gitlab" | ||||||
| 	"github.com/dexidp/dex/connector/ldap" | 	"github.com/dexidp/dex/connector/ldap" | ||||||
| @@ -439,6 +440,7 @@ var ConnectorsConfig = map[string]func() ConnectorConfig{ | |||||||
| 	"authproxy":       func() ConnectorConfig { return new(authproxy.Config) }, | 	"authproxy":       func() ConnectorConfig { return new(authproxy.Config) }, | ||||||
| 	"linkedin":        func() ConnectorConfig { return new(linkedin.Config) }, | 	"linkedin":        func() ConnectorConfig { return new(linkedin.Config) }, | ||||||
| 	"microsoft":       func() ConnectorConfig { return new(microsoft.Config) }, | 	"microsoft":       func() ConnectorConfig { return new(microsoft.Config) }, | ||||||
|  | 	"bitbucket-cloud": func() ConnectorConfig { return new(bitbucketcloud.Config) }, | ||||||
| 	// Keep around for backwards compatibility. | 	// Keep around for backwards compatibility. | ||||||
| 	"samlExperimental": func() ConnectorConfig { return new(saml.Config) }, | 	"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