Skip to content
Snippets Groups Projects
Commit 8c75d85b authored by Ed Tan's avatar Ed Tan
Browse files

Add Bitbucket connector

parent b58053ee
No related branches found
No related tags found
No related merge requests found
# 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:
......
// 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, &notifyRefreshTokenSource{
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
}
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)
}
}
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) },
}
......
// 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",
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment