From 4fc76e6984700b849a18f6745980ec7a2152fbe4 Mon Sep 17 00:00:00 2001 From: Daniel Hilgarth Date: Sun, 21 May 2023 14:26:49 +0200 Subject: [PATCH] feat: Add support for OAuth2.0 authentication via client credentials --- README.md | 13 ++--- bitbucket/api/v1/client.go | 45 +++++++++++++++-- bitbucket/api/v1/client_test.go | 23 ++++++--- bitbucket/api/v1/group_members.go | 6 +-- bitbucket/api/v1/group_members_test.go | 18 +++---- bitbucket/api/v1/groups.go | 8 +-- bitbucket/api/v1/groups_test.go | 26 ++++------ bitbucket/api/v1/test_client.go | 22 ++++++++ bitbucket/data_source_bitbucket_user_test.go | 5 +- bitbucket/provider.go | 53 +++++++++++++++----- bitbucket/provider_test.go | 24 +++++++-- bitbucket/test_client.go | 24 +++++++++ 12 files changed, 199 insertions(+), 68 deletions(-) create mode 100644 bitbucket/api/v1/test_client.go create mode 100644 bitbucket/test_client.go diff --git a/README.md b/README.md index e51db27..2ac9b4f 100644 --- a/README.md +++ b/README.md @@ -56,16 +56,17 @@ your account, and it will tear them down afterwards to ensure it leaves your acc You will also require a UUID of another account that is a member of your workspace in order for the `bitbucket_user_permission` tests to run, as Bitbucket's API will reject the account owner's UUID. -* `BITBUCKET_USERNAME` - Username of the account to run the tests against -* `BITBUCKET_PASSWORD` - Password of the account to run the tests against +* `BITBUCKET_USERNAME` - Username of the account to run the tests against. Even if `BITBUCKET_AUTH_METHOD` is set to `oauth`, this is still required, as this value is also used as the workspace name. +* `BITBUCKET_PASSWORD` - App Password of the account to run the tests against. Don't set if `BITBUCKET_AUTH_METHOD` is set to `oauth`. * `BITBUCKET_MEMBER_ACCOUNT_UUID` - Account UUID of the member who is part of your account +* `BITBUCKET_OAUTH_CLIENT_ID` - The "Key" from an [OAuth consumer](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/). Make sure to mark it as private. +* `BITBUCKET_OAUTH_CLIENT_SECRET` - The "Secret" from the OAuth consumer. +* `BITBUCKET_AUTH_METHOD` - If set to `oauth`, it will use the OAuth credentials for all operations, otherwise the username and password. In any case, the OAuth credentials are required for the `NewOAuthClient` test +**NOTE**: `BITBUCKET_PASSWORD` must be an [app password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/). If you use the account password, some tests will fail **NOTE**: if a test fails, it may leave dangling resources in your account so please bear this in mind. +**NOTE**: Tests that create a group permission in a repository (resource `bitbucket_group_permission`) will fail when using OAuth authorization, because only app passwords can be used for that API, see [the official documentation](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-groups-group-slug-put). -If you have two-factor authentication enabled, then be sure to set up an [app password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/) and use that instead. -```shell -$ BITBUCKET_USERNAME=myUsername BITBUCKET_PASSWORD=myPassword BITBUCKET_MEMBER_ACCOUNT_UUID=myMemberUUID make testacc -``` ### Documentation Every data source or resource added must have an accompanying docs page (see `docs` directory for examples). diff --git a/bitbucket/api/v1/client.go b/bitbucket/api/v1/client.go index 270c65d..cf10892 100644 --- a/bitbucket/api/v1/client.go +++ b/bitbucket/api/v1/client.go @@ -4,10 +4,14 @@ import ( "log" "net/http" "net/url" + + "golang.org/x/net/context" + "golang.org/x/oauth2/bitbucket" + "golang.org/x/oauth2/clientcredentials" ) type Client struct { - Auth *Auth + Auth Auth ApiBaseUrl *url.URL HttpClient *http.Client @@ -16,12 +20,47 @@ type Client struct { GroupMembers *GroupMembers } -type Auth struct { +type Auth interface { + SetRequestAuth(request *http.Request) +} + +type BasicAuth struct { Username string Password string } -func NewClient(auth *Auth) *Client { +type BearerAuth struct { + Token string +} + +func (auth *BasicAuth) SetRequestAuth(request *http.Request) { + request.SetBasicAuth(auth.Username, auth.Password) +} + +func (auth *BearerAuth) SetRequestAuth(request *http.Request) { + request.Header.Set("Authorization", "Bearer "+auth.Token) +} + +func NewOAuthClient(clientId string, clientSecret string) *Client { + ctx := context.Background() + conf := &clientcredentials.Config{ + ClientID: clientId, + ClientSecret: clientSecret, + TokenURL: bitbucket.Endpoint.TokenURL, + } + + tok, err := conf.Token(ctx) + if err != nil { + log.Fatal(err) + } + return newClient(&BearerAuth{Token: tok.AccessToken}) +} + +func NewBasicAuthClient(username string, password string) *Client { + return newClient(&BasicAuth{Username: username, Password: password}) +} + +func newClient(auth Auth) *Client { apiBaseUrl, err := url.Parse("https://api.bitbucket.org/1.0") if err != nil { log.Fatal(err) diff --git a/bitbucket/api/v1/client_test.go b/bitbucket/api/v1/client_test.go index 7f34413..18543de 100644 --- a/bitbucket/api/v1/client_test.go +++ b/bitbucket/api/v1/client_test.go @@ -2,20 +2,29 @@ package v1 import ( "net/http" + "os" "testing" "github.com/stretchr/testify/assert" ) -func TestNewClient(t *testing.T) { - auth := &Auth{ - Username: "test", - Password: "test", - } - client := NewClient(auth) +func TestNewBasicAuthClient(t *testing.T) { + client := NewBasicAuthClient("test", "password") assert.Equal(t, "https://api.bitbucket.org/1.0", client.ApiBaseUrl.String()) - assert.Equal(t, auth, client.Auth) + assert.IsType(t, &BasicAuth{}, client.Auth) + assert.Equal(t, client.Auth.(*BasicAuth).Username, "test") + assert.Equal(t, client.Auth.(*BasicAuth).Password, "password") + assert.IsType(t, &Groups{}, client.Groups) + assert.IsType(t, &http.Client{}, client.HttpClient) +} + +func TestNewOAuthClient(t *testing.T) { + client := NewOAuthClient(os.Getenv("BITBUCKET_OAUTH_CLIENT_ID"), os.Getenv("BITBUCKET_OAUTH_CLIENT_SECRET")) + + assert.Equal(t, "https://api.bitbucket.org/1.0", client.ApiBaseUrl.String()) + assert.IsType(t, &BearerAuth{}, client.Auth) + assert.NotEmpty(t, client.Auth.(*BearerAuth).Token) assert.IsType(t, &Groups{}, client.Groups) assert.IsType(t, &http.Client{}, client.HttpClient) } diff --git a/bitbucket/api/v1/group_members.go b/bitbucket/api/v1/group_members.go index fa07fe2..e053fb7 100644 --- a/bitbucket/api/v1/group_members.go +++ b/bitbucket/api/v1/group_members.go @@ -39,7 +39,7 @@ func (gm *GroupMembers) Get(gmo *GroupMemberOptions) ([]GroupMember, error) { return nil, err } - request.SetBasicAuth(gm.client.Auth.Username, gm.client.Auth.Password) + gm.client.Auth.SetRequestAuth(request) response, err := gm.client.HttpClient.Do(request) if err != nil { @@ -68,7 +68,7 @@ func (gm *GroupMembers) Create(gmo *GroupMemberOptions) (*GroupMember, error) { return nil, err } - request.SetBasicAuth(gm.client.Auth.Username, gm.client.Auth.Password) + gm.client.Auth.SetRequestAuth(request) request.Header.Set("Content-Type", "application/json") response, err := gm.client.HttpClient.Do(request) @@ -98,7 +98,7 @@ func (gm *GroupMembers) Delete(gmo *GroupMemberOptions) error { return err } - request.SetBasicAuth(gm.client.Auth.Username, gm.client.Auth.Password) + gm.client.Auth.SetRequestAuth(request) response, err := gm.client.HttpClient.Do(request) if err != nil { diff --git a/bitbucket/api/v1/group_members_test.go b/bitbucket/api/v1/group_members_test.go index f5938c8..5438f10 100644 --- a/bitbucket/api/v1/group_members_test.go +++ b/bitbucket/api/v1/group_members_test.go @@ -13,17 +13,15 @@ func TestGroupMembers(t *testing.T) { t.Skip("ENV TF_ACC=1 not set") } - c := NewClient(&Auth{ - Username: os.Getenv("BITBUCKET_USERNAME"), - Password: os.Getenv("BITBUCKET_PASSWORD"), - }) + c := NewClient() var group *Group + owner := os.Getenv("BITBUCKET_USERNAME") t.Run("setup", func(t *testing.T) { group, _ = c.Groups.Create( &GroupOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Name: "tf-bb-group-members-test" + acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum), }, ) @@ -33,7 +31,7 @@ func TestGroupMembers(t *testing.T) { t.Run("create", func(t *testing.T) { result, err := c.GroupMembers.Create( &GroupMemberOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Slug: group.Slug, UserUuid: group.Owner.Uuid, }, @@ -47,7 +45,7 @@ func TestGroupMembers(t *testing.T) { t.Run("get", func(t *testing.T) { members, err := c.GroupMembers.Get( &GroupMemberOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Slug: group.Slug, }, ) @@ -60,7 +58,7 @@ func TestGroupMembers(t *testing.T) { t.Run("delete", func(t *testing.T) { err := c.GroupMembers.Delete( &GroupMemberOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Slug: group.Slug, UserUuid: group.Owner.Uuid, }, @@ -69,7 +67,7 @@ func TestGroupMembers(t *testing.T) { members, err := c.GroupMembers.Get( &GroupMemberOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Slug: group.Slug, }, ) @@ -79,7 +77,7 @@ func TestGroupMembers(t *testing.T) { t.Run("teardown", func(t *testing.T) { opt := &GroupOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Slug: group.Slug, } err := c.Groups.Delete(opt) diff --git a/bitbucket/api/v1/groups.go b/bitbucket/api/v1/groups.go index e3472e2..319c740 100644 --- a/bitbucket/api/v1/groups.go +++ b/bitbucket/api/v1/groups.go @@ -37,7 +37,7 @@ func (g *Groups) Get(gro *GroupOptions) (*Group, error) { return nil, err } - request.SetBasicAuth(g.client.Auth.Username, g.client.Auth.Password) + g.client.Auth.SetRequestAuth(request) response, err := g.client.HttpClient.Do(request) if err != nil { @@ -74,7 +74,7 @@ func (g *Groups) Create(gro *GroupOptions) (*Group, error) { return nil, err } - request.SetBasicAuth(g.client.Auth.Username, g.client.Auth.Password) + g.client.Auth.SetRequestAuth(request) request.Header.Set("Content-Type", "application/x-www-form-urlencoded") response, err := g.client.HttpClient.Do(request) @@ -127,7 +127,7 @@ func (g *Groups) Update(gro *GroupOptions) (*Group, error) { return nil, err } - request.SetBasicAuth(g.client.Auth.Username, g.client.Auth.Password) + g.client.Auth.SetRequestAuth(request) request.Header.Set("Content-Type", "application/json") response, err := g.client.HttpClient.Do(request) @@ -157,7 +157,7 @@ func (g *Groups) Delete(gro *GroupOptions) error { return err } - request.SetBasicAuth(g.client.Auth.Username, g.client.Auth.Password) + g.client.Auth.SetRequestAuth(request) response, err := g.client.HttpClient.Do(request) if err != nil { diff --git a/bitbucket/api/v1/groups_test.go b/bitbucket/api/v1/groups_test.go index c6c8f78..06c9a6e 100644 --- a/bitbucket/api/v1/groups_test.go +++ b/bitbucket/api/v1/groups_test.go @@ -13,18 +13,16 @@ func TestGroups(t *testing.T) { t.Skip("ENV TF_ACC=1 not set") } - c := NewClient(&Auth{ - Username: os.Getenv("BITBUCKET_USERNAME"), - Password: os.Getenv("BITBUCKET_PASSWORD"), - }) + c := NewClient() var groupResourceSlug string name := "tf-bb-group-test" + acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum) + owner := os.Getenv("BITBUCKET_USERNAME") t.Run("create", func(t *testing.T) { opt := &GroupOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Name: name, } @@ -39,7 +37,7 @@ func TestGroups(t *testing.T) { t.Run("get", func(t *testing.T) { opt := &GroupOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Slug: groupResourceSlug, } group, err := c.Groups.Get(opt) @@ -52,7 +50,7 @@ func TestGroups(t *testing.T) { t.Run("update", func(t *testing.T) { opt := &GroupOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Slug: groupResourceSlug, Permission: "write", } @@ -66,7 +64,7 @@ func TestGroups(t *testing.T) { t.Run("delete", func(t *testing.T) { opt := &GroupOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Slug: groupResourceSlug, } err := c.Groups.Delete(opt) @@ -79,18 +77,16 @@ func TestGroupsGracefullyHandleNoReturnedGroupsForInvalidSlug(t *testing.T) { t.Skip("ENV TF_ACC=1 not set") } - c := NewClient(&Auth{ - Username: os.Getenv("BITBUCKET_USERNAME"), - Password: os.Getenv("BITBUCKET_PASSWORD"), - }) + c := NewClient() var groupResourceSlug string name := "TF-BB-Group-Test" + owner := os.Getenv("BITBUCKET_USERNAME") t.Run("create", func(t *testing.T) { opt := &GroupOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Name: name, } @@ -105,7 +101,7 @@ func TestGroupsGracefullyHandleNoReturnedGroupsForInvalidSlug(t *testing.T) { t.Run("get", func(t *testing.T) { opt := &GroupOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Slug: name, // Slugs are lowercase and the BB's API is case-sensitive, this will trigger a fail response } group, err := c.Groups.Get(opt) @@ -115,7 +111,7 @@ func TestGroupsGracefullyHandleNoReturnedGroupsForInvalidSlug(t *testing.T) { t.Run("delete", func(t *testing.T) { opt := &GroupOptions{ - OwnerUuid: c.Auth.Username, + OwnerUuid: owner, Slug: groupResourceSlug, } err := c.Groups.Delete(opt) diff --git a/bitbucket/api/v1/test_client.go b/bitbucket/api/v1/test_client.go new file mode 100644 index 0000000..39e2abe --- /dev/null +++ b/bitbucket/api/v1/test_client.go @@ -0,0 +1,22 @@ +package v1 + +import ( + "os" + "strings" +) + +// NewAuthenticatedBasicClient creates a new BasicClient with credentials from environment variables +func NewClient() *Client { + username := os.Getenv("BITBUCKET_USERNAME") + password := os.Getenv("BITBUCKET_PASSWORD") + oauthClientId := os.Getenv("BITBUCKET_OAUTH_CLIENT_ID") + oauthClientSecret := os.Getenv("BITBUCKET_OAUTH_CLIENT_SECRET") + authMethod := os.Getenv("BITBUCKET_AUTH_METHOD") + + // no detailed check necessary, it was already performed by provider_test.go + if strings.EqualFold(authMethod, "oauth") { + return NewOAuthClient(oauthClientId, oauthClientSecret) + } else { + return NewBasicAuthClient(username, password) + } +} diff --git a/bitbucket/data_source_bitbucket_user_test.go b/bitbucket/data_source_bitbucket_user_test.go index 03dff87..db70fac 100644 --- a/bitbucket/data_source_bitbucket_user_test.go +++ b/bitbucket/data_source_bitbucket_user_test.go @@ -35,10 +35,7 @@ func TestAccBitbucketUserDataSource_basic(t *testing.T) { func getCurrentUser() (*gobb.User, error) { if _, isSet := os.LookupEnv("TF_ACC"); isSet { - client := gobb.NewBasicAuth( - os.Getenv("BITBUCKET_USERNAME"), - os.Getenv("BITBUCKET_PASSWORD"), - ) + client := NewClient() return client.User.Profile() } else { diff --git a/bitbucket/provider.go b/bitbucket/provider.go index f5541ed..fa89ae0 100644 --- a/bitbucket/provider.go +++ b/bitbucket/provider.go @@ -15,16 +15,28 @@ func Provider() *schema.Provider { Schema: map[string]*schema.Schema{ "username": { Type: schema.TypeString, - Required: true, + Optional: true, DefaultFunc: schema.EnvDefaultFunc("BITBUCKET_USERNAME", nil), Description: "Username to authenticate with Bitbucket.", }, "password": { Type: schema.TypeString, - Required: true, + Optional: true, DefaultFunc: schema.EnvDefaultFunc("BITBUCKET_PASSWORD", nil), Description: "Password to authenticate with Bitbucket.", }, + "oauth_client_id": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("BITBUCKET_OAUTH_CLIENT_ID", nil), + Description: "Client ID for OAuth authentication with Bitbucket.", + }, + "oauth_client_secret": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("BITBUCKET_OAUTH_CLIENT_SECRET", nil), + Description: "Client secret for OAuth authentication with Bitbucket.", + }, }, DataSourcesMap: map[string]*schema.Resource{ @@ -73,20 +85,35 @@ type Clients struct { } func configureProvider(ctx context.Context, resourceData *schema.ResourceData) (interface{}, diag.Diagnostics) { - client := gobb.NewBasicAuth( - resourceData.Get("username").(string), - resourceData.Get("password").(string), - ) + username := resourceData.Get("username").(string) + password := resourceData.Get("password").(string) + oauthClientId := resourceData.Get("oauth_client_id").(string) + oauthClientSecret := resourceData.Get("oauth_client_secret").(string) + + var client *gobb.Client + var v1Client *v1.Client + + if username != "" && password != "" { + client = gobb.NewBasicAuth(username, password) + v1Client = v1.NewBasicAuthClient(username, password) + } else if oauthClientId != "" && oauthClientSecret != "" { + client = gobb.NewOAuthClientCredentials(oauthClientId, oauthClientSecret) + v1Client = v1.NewOAuthClient(oauthClientId, oauthClientSecret) + } else if username != "" && password == "" { + diag.Errorf("`username` is set but `password` is not.") + } else if username == "" && password != "" { + diag.Errorf("`password` is set but `username` is not.") + } else if oauthClientId != "" && oauthClientSecret == "" { + diag.Errorf("`oauth_client_id` is set but `oauth_client_secret` is not.") + } else if oauthClientId == "" && oauthClientSecret != "" { + diag.Errorf("`oauth_client_secret` is set but `oauth_client_id` is not.") + } else { + diag.Errorf("Either `username` and `password` or `oauth_client_id` and `oauth_client_secret` need to be set for acceptance tests") + } + client.Pagelen = 100 client.MaxDepth = 10 - v1Client := v1.NewClient( - &v1.Auth{ - Username: resourceData.Get("username").(string), - Password: resourceData.Get("password").(string), - }, - ) - clients := &Clients{ V1: v1Client, V2: client, diff --git a/bitbucket/provider_test.go b/bitbucket/provider_test.go index f4ba313..cae9164 100644 --- a/bitbucket/provider_test.go +++ b/bitbucket/provider_test.go @@ -1,9 +1,12 @@ package bitbucket import ( + "context" "os" + "strings" "testing" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/stretchr/testify/assert" ) @@ -13,6 +16,13 @@ var testAccProviders map[string]func() (*schema.Provider, error) func init() { testAccProvider = Provider() + original := testAccProvider.ConfigureContextFunc + testAccProvider.ConfigureContextFunc = func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) { + if strings.EqualFold(os.Getenv("BITBUCKET_AUTH_METHOD"), "oauth") { + d.Set("password", "") // Delete the password to ensure the provider logic picks OAuth for authentication + } + return original(ctx, d) + } testAccProviders = map[string]func() (*schema.Provider, error){ "bitbucket": func() (*schema.Provider, error) { return testAccProvider, nil @@ -27,8 +37,16 @@ func TestProvider(t *testing.T) { func testAccPreCheck(t *testing.T) { username := os.Getenv("BITBUCKET_USERNAME") - assert.NotEqual(t, "", username, "BITBUCKET_USERNAME must be set for acceptance tests") - password := os.Getenv("BITBUCKET_PASSWORD") - assert.NotEqual(t, "", password, "BITBUCKET_PASSWORD must be set for acceptance tests") + oauthClientId := os.Getenv("BITBUCKET_OAUTH_CLIENT_ID") + oauthClientSecret := os.Getenv("BITBUCKET_OAUTH_CLIENT_SECRET") + authMethod := os.Getenv("BITBUCKET_AUTH_METHOD") + + if strings.EqualFold(authMethod, "oauth") { + assert.NotEqual(t, "", oauthClientId, "BITBUCKET_OAUTH_CLIENT_ID must be set for acceptance tests") + assert.NotEqual(t, "", oauthClientSecret, "BITBUCKET_OAUTH_CLIENT_SECRET must be set for acceptance tests") + } else { + assert.NotEqual(t, "", username, "BITBUCKET_USERNAME must be set for acceptance tests") + assert.NotEqual(t, "", password, "BITBUCKET_PASSWORD must be set for acceptance tests") + } } diff --git a/bitbucket/test_client.go b/bitbucket/test_client.go new file mode 100644 index 0000000..f143a57 --- /dev/null +++ b/bitbucket/test_client.go @@ -0,0 +1,24 @@ +package bitbucket + +import ( + "os" + "strings" + + gobb "github.com/ktrysmt/go-bitbucket" +) + +// NewAuthenticatedBasicClient creates a new BasicClient with credentials from environment variables +func NewClient() *gobb.Client { + username := os.Getenv("BITBUCKET_USERNAME") + password := os.Getenv("BITBUCKET_PASSWORD") + oauthClientId := os.Getenv("BITBUCKET_OAUTH_CLIENT_ID") + oauthClientSecret := os.Getenv("BITBUCKET_OAUTH_CLIENT_SECRET") + authMethod := os.Getenv("BITBUCKET_AUTH_METHOD") + + // no detailed check necessary, it was already performed by provider_test.go + if strings.EqualFold(authMethod, "oauth") { + return gobb.NewOAuthClientCredentials(oauthClientId, oauthClientSecret) + } else { + return gobb.NewBasicAuth(username, password) + } +}