Repository: mrparkers/terraform-provider-keycloak
Pull request #96 (2019-02-25 22:42:07)

new resource: keycloak_openid_client_optional_scopes


Patch:
diff --git a/docs/resources/keycloak_openid_client_default_scopes.md b/docs/resources/keycloak_openid_client_default_scopes.md
index 8fdfc05..56c05ea 100644
--- a/docs/resources/keycloak_openid_client_default_scopes.md
+++ b/docs/resources/keycloak_openid_client_default_scopes.md
@@ -11,10 +11,10 @@ This means that once Terraform controls a particular client's default scopes,
 it will attempt to remove any default scopes that were attached manually,
 and it will attempt to add any default scopes that were detached manually.
 
-By default, Keycloak sets the `profile` and `email` scopes as default scopes
-for every newly created client. If you create this resource for the first
-time and do not include these scopes, a following run of `terraform plan`
-will result in changes.
+By default, Keycloak sets the `profile`, `email`, `roles`, and `web-origins`
+scopes as default scopes for every newly created client. If you create this
+resource for the first time and do not include these scopes, a following run
+of `terraform plan` will result in changes.
 
 ### Example Usage
 
@@ -43,6 +43,8 @@ resource "keycloak_openid_client_default_scopes" "client_default_scopes" {
     default_scopes = [
         "profile",
         "email",
+        "roles",
+        "web-origins",
         "${keycloak_openid_client_scope.client_scope.name}"
     ]
 }
diff --git a/docs/resources/keycloak_openid_client_optional_scopes.md b/docs/resources/keycloak_openid_client_optional_scopes.md
new file mode 100644
index 0000000..960a3a8
--- /dev/null
+++ b/docs/resources/keycloak_openid_client_optional_scopes.md
@@ -0,0 +1,65 @@
+# keycloak_openid_client_optional_scopes
+
+Allows for managing a Keycloak client's optional client scopes. An optional
+scope that is attached to a client using the OpenID Connect protocol will
+allow a client to request it using the OAuth 2.0 `scope` parameter. When
+requested, the scope's protocol mappers defined within that scope will be
+used to build claims for this client.
+
+Note that this resource attempts to be an **authoritative** source over
+optional scopes for a Keycloak client using the OpenID Connect protocol.
+This means that once Terraform controls a particular client's optional scopes,
+it will attempt to remove any optional scopes that were attached manually,
+and it will attempt to add any optional scopes that were detached manually.
+
+By default, Keycloak sets the `address`, `phone` and `offline_access` scopes as
+optional scopes for every newly created client. If you create this resource for
+the first time and do not include these scopes, a following run of `terraform plan`
+will result in changes.
+
+### Example Usage
+
+```hcl
+resource "keycloak_realm" "realm" {
+    realm   = "my-realm"
+    enabled = true
+}
+
+resource "keycloak_openid_client" "client" {
+    realm_id    = "${keycloak_realm.realm.id}"
+    client_id   = "test-client"
+
+    access_type = "CONFIDENTIAL"
+}
+
+resource "keycloak_openid_client_scope" "client_scope" {
+    realm_id = "${keycloak_realm.realm.id}"
+    name     = "test-client-scope"
+}
+
+resource "keycloak_openid_client_optional_scopes" "client_optional_scopes" {
+    realm_id       = "${keycloak_realm.realm.id}"
+    client_id      = "${keycloak_openid_client.client.id}"
+
+    optional_scopes = [
+        "address",
+        "phone",
+        "offline_access",
+        "${keycloak_openid_client_scope.client_scope.name}"
+    ]
+}
+
+```
+
+### Argument Reference
+
+The following arguments are supported:
+
+- `realm_id` - (Required) The realm this client and scopes exists in.
+- `client_id` - (Required) The ID of the client to attach optional scopes to. Note that this is the unique ID of the client generated by Keycloak.
+- `optional_scopes` - (Required) An array of client scope names to attach to this client as optional scopes.
+
+### Import
+
+This resource does not support import. Instead of importing, feel free to create this resource
+as if it did not already exist on the server.
diff --git a/example/main.tf b/example/main.tf
index c28a3ca..8142678 100644
--- a/example/main.tf
+++ b/example/main.tf
@@ -88,8 +88,16 @@ resource "keycloak_openid_client" "test_client" {
 	client_secret         = "secret"
 }
 
-resource "keycloak_openid_client_scope" "test_client_scope" {
-	name                = "foo1"
+resource "keycloak_openid_client_scope" "test_default_client_scope" {
+	name                = "test-default-client-scope"
+	realm_id            = "${keycloak_realm.test.id}"
+
+	description         = "test"
+	consent_screen_text = "hello"
+}
+
+resource "keycloak_openid_client_scope" "test_optional_client_scope" {
+	name                = "test-optional-client-scope"
 	realm_id            = "${keycloak_realm.test.id}"
 
 	description         = "test"
@@ -105,7 +113,19 @@ resource "keycloak_openid_client_default_scopes" "default_client_scopes" {
 		"email",
 		"roles",
 		"web-origins",
-		"${keycloak_openid_client_scope.test_client_scope.name}"
+		"${keycloak_openid_client_scope.test_default_client_scope.name}"
+	]
+}
+
+resource "keycloak_openid_client_optional_scopes" "optional_client_scopes" {
+	realm_id       = "${keycloak_realm.test.id}"
+	client_id      = "${keycloak_openid_client.test_client.id}"
+
+	optional_scopes = [
+		"address",
+		"phone",
+		"offline_access",
+		"${keycloak_openid_client_scope.test_optional_client_scope.name}"
 	]
 }
 
@@ -195,7 +215,7 @@ resource "keycloak_openid_user_attribute_protocol_mapper" "map_user_attributes_c
 resource "keycloak_openid_user_attribute_protocol_mapper" "map_user_attributes_client_scope" {
 	name            = "tf-test-open-id-user-attribute-protocol-mapper-client-scope"
 	realm_id        = "${keycloak_realm.test.id}"
-	client_scope_id = "${keycloak_openid_client_scope.test_client_scope.id}"
+	client_scope_id = "${keycloak_openid_client_scope.test_default_client_scope.id}"
 	user_attribute  = "foo2"
 	claim_name      = "bar2"
 }
@@ -210,7 +230,7 @@ resource "keycloak_openid_group_membership_protocol_mapper" "map_group_membershi
 resource "keycloak_openid_group_membership_protocol_mapper" "map_group_memberships_client_scope" {
 	name            = "tf-test-open-id-group-membership-protocol-mapper-client-scope"
 	realm_id        = "${keycloak_realm.test.id}"
-	client_scope_id = "${keycloak_openid_client_scope.test_client_scope.id}"
+	client_scope_id = "${keycloak_openid_client_scope.test_optional_client_scope.id}"
 	claim_name      = "bar2"
 }
 
@@ -223,7 +243,7 @@ resource "keycloak_openid_full_name_protocol_mapper" "map_full_names_client" {
 resource "keycloak_openid_full_name_protocol_mapper" "map_full_names_client_scope" {
 	name            = "tf-test-open-id-full-name-protocol-mapper-client-scope"
 	realm_id        = "${keycloak_realm.test.id}"
-	client_scope_id = "${keycloak_openid_client_scope.test_client_scope.id}"
+	client_scope_id = "${keycloak_openid_client_scope.test_default_client_scope.id}"
 }
 
 resource "keycloak_openid_user_property_protocol_mapper" "map_user_properties_client" {
@@ -237,7 +257,7 @@ resource "keycloak_openid_user_property_protocol_mapper" "map_user_properties_cl
 resource "keycloak_openid_user_property_protocol_mapper" "map_user_properties_client_scope" {
 	name            = "tf-test-open-id-user-property-protocol-mapper-client-scope"
 	realm_id        = "${keycloak_realm.test.id}"
-	client_scope_id = "${keycloak_openid_client_scope.test_client_scope.id}"
+	client_scope_id = "${keycloak_openid_client_scope.test_optional_client_scope.id}"
 	user_property   = "foo2"
 	claim_name      = "bar2"
 }
@@ -254,7 +274,7 @@ resource "keycloak_openid_hardcoded_claim_protocol_mapper" "hardcoded_claim_clie
 resource "keycloak_openid_hardcoded_claim_protocol_mapper" "hardcoded_claim_client_scope" {
 	name            = "tf-test-open-id-hardcoded-claim-protocol-mapper-client-scope"
 	realm_id        = "${keycloak_realm.test.id}"
-	client_scope_id = "${keycloak_openid_client_scope.test_client_scope.id}"
+	client_scope_id = "${keycloak_openid_client_scope.test_default_client_scope.id}"
 
 	claim_name      = "foo"
 	claim_value     = "bar"
diff --git a/keycloak/openid_client.go b/keycloak/openid_client.go
index b1bb28a..e97985e 100644
--- a/keycloak/openid_client.go
+++ b/keycloak/openid_client.go
@@ -121,10 +121,10 @@ func (keycloakClient *KeycloakClient) DeleteOpenidClient(realmId, id string) err
 	return keycloakClient.delete(fmt.Sprintf("/realms/%s/clients/%s", realmId, id))
 }
 
-func (keycloakClient *KeycloakClient) GetOpenidClientDefaultScopes(realmId, clientId string) ([]*OpenidClientScope, error) {
+func (keycloakClient *KeycloakClient) getOpenidClientScopes(realmId, clientId, t string) ([]*OpenidClientScope, error) {
 	var scopes []*OpenidClientScope
 
-	err := keycloakClient.get(fmt.Sprintf("/realms/%s/clients/%s/default-client-scopes", realmId, clientId), &scopes)
+	err := keycloakClient.get(fmt.Sprintf("/realms/%s/clients/%s/%s-client-scopes", realmId, clientId, t), &scopes)
 	if err != nil {
 		return nil, err
 	}
@@ -132,7 +132,15 @@ func (keycloakClient *KeycloakClient) GetOpenidClientDefaultScopes(realmId, clie
 	return scopes, nil
 }
 
-func (keycloakClient *KeycloakClient) AttachOpenidClientDefaultScopes(realmId, clientId string, scopeNames []string) error {
+func (keycloakClient *KeycloakClient) GetOpenidClientDefaultScopes(realmId, clientId string) ([]*OpenidClientScope, error) {
+	return keycloakClient.getOpenidClientScopes(realmId, clientId, "default")
+}
+
+func (keycloakClient *KeycloakClient) GetOpenidClientOptionalScopes(realmId, clientId string) ([]*OpenidClientScope, error) {
+	return keycloakClient.getOpenidClientScopes(realmId, clientId, "optional")
+}
+
+func (keycloakClient *KeycloakClient) attachOpenidClientScopes(realmId, clientId, t string, scopeNames []string) error {
 	openidClient, err := keycloakClient.GetOpenidClient(realmId, clientId)
 	if err != nil && ErrorIs404(err) {
 		return fmt.Errorf("validation error: client with id %s does not exist", clientId)
@@ -149,8 +157,33 @@ func (keycloakClient *KeycloakClient) AttachOpenidClientDefaultScopes(realmId, c
 		return err
 	}
 
+	var attachedClientScopes []*OpenidClientScope
+	var duplicateScopeAssignmentErrorMessage string
+	switch t {
+	case "optional":
+		attachedDefaultClientScopes, err := keycloakClient.GetOpenidClientDefaultScopes(realmId, clientId)
+		if err != nil {
+			return err
+		}
+		attachedClientScopes = append(attachedClientScopes, attachedDefaultClientScopes...)
+		duplicateScopeAssignmentErrorMessage = "validation error: scope %s is already attached to client as a default scope"
+	case "default":
+		attachedOptionalClientScopes, err := keycloakClient.GetOpenidClientOptionalScopes(realmId, clientId)
+		if err != nil {
+			return err
+		}
+		attachedClientScopes = append(attachedClientScopes, attachedOptionalClientScopes...)
+		duplicateScopeAssignmentErrorMessage = "validation error: scope %s is already attached to client as an optional scope"
+	}
+
 	for _, openidClientScope := range allOpenidClientScopes {
-		err := keycloakClient.put(fmt.Sprintf("/realms/%s/clients/%s/default-client-scopes/%s", realmId, clientId, openidClientScope.Id), nil)
+		for _, attachedClientScope := range attachedClientScopes {
+			if openidClientScope.Id == attachedClientScope.Id {
+				return fmt.Errorf(duplicateScopeAssignmentErrorMessage, attachedClientScope.Name)
+			}
+		}
+
+		err := keycloakClient.put(fmt.Sprintf("/realms/%s/clients/%s/%s-client-scopes/%s", realmId, clientId, t, openidClientScope.Id), nil)
 		if err != nil {
 			return err
 		}
@@ -159,14 +192,22 @@ func (keycloakClient *KeycloakClient) AttachOpenidClientDefaultScopes(realmId, c
 	return nil
 }
 
-func (keycloakClient *KeycloakClient) DetachOpenidClientDefaultScopes(realmId, clientId string, scopeNames []string) error {
+func (keycloakClient *KeycloakClient) AttachOpenidClientDefaultScopes(realmId, clientId string, scopeNames []string) error {
+	return keycloakClient.attachOpenidClientScopes(realmId, clientId, "default", scopeNames)
+}
+
+func (keycloakClient *KeycloakClient) AttachOpenidClientOptionalScopes(realmId, clientId string, scopeNames []string) error {
+	return keycloakClient.attachOpenidClientScopes(realmId, clientId, "optional", scopeNames)
+}
+
+func (keycloakClient *KeycloakClient) detachOpenidClientScopes(realmId, clientId, t string, scopeNames []string) error {
 	allOpenidClientScopes, err := keycloakClient.listOpenidClientScopesWithFilter(realmId, includeOpenidClientScopesMatchingNames(scopeNames))
 	if err != nil {
 		return err
 	}
 
 	for _, openidClientScope := range allOpenidClientScopes {
-		err := keycloakClient.delete(fmt.Sprintf("/realms/%s/clients/%s/default-client-scopes/%s", realmId, clientId, openidClientScope.Id))
+		err := keycloakClient.delete(fmt.Sprintf("/realms/%s/clients/%s/%s-client-scopes/%s", realmId, clientId, t, openidClientScope.Id))
 		if err != nil {
 			return err
 		}
@@ -174,3 +215,11 @@ func (keycloakClient *KeycloakClient) DetachOpenidClientDefaultScopes(realmId, c
 
 	return nil
 }
+
+func (keycloakClient *KeycloakClient) DetachOpenidClientDefaultScopes(realmId, clientId string, scopeNames []string) error {
+	return keycloakClient.detachOpenidClientScopes(realmId, clientId, "default", scopeNames)
+}
+
+func (keycloakClient *KeycloakClient) DetachOpenidClientOptionalScopes(realmId, clientId string, scopeNames []string) error {
+	return keycloakClient.detachOpenidClientScopes(realmId, clientId, "optional", scopeNames)
+}
diff --git a/mkdocs.yml b/mkdocs.yml
index bd7c726..e3268ef 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -11,6 +11,7 @@ nav:
   - keycloak_openid_client: resources/keycloak_openid_client.md
   - keycloak_openid_client_scope: resources/keycloak_openid_client_scope.md
   - keycloak_openid_client_default_scopes: resources/keycloak_openid_client_default_scopes.md
+  - keycloak_openid_client_optional_scopes: resources/keycloak_openid_client_optional_scopes.md
   - keycloak_openid_user_attribute_protocol_mapper: resources/keycloak_openid_user_attribute_protocol_mapper.md
   - keycloak_openid_user_property_protocol_mapper: resources/keycloak_openid_user_property_protocol_mapper.md
   - keycloak_openid_group_membership_protocol_mapper: resources/keycloak_openid_group_membership_protocol_mapper.md
diff --git a/provider/keycloak_openid_client_optional_scopes.go b/provider/keycloak_openid_client_optional_scopes.go
new file mode 100644
index 0000000..8450e0f
--- /dev/null
+++ b/provider/keycloak_openid_client_optional_scopes.go
@@ -0,0 +1,127 @@
+package provider
+
+import (
+	"fmt"
+	"github.com/hashicorp/terraform/helper/schema"
+	"github.com/mrparkers/terraform-provider-keycloak/keycloak"
+)
+
+func resourceKeycloakOpenidClientOptionalScopes() *schema.Resource {
+	return &schema.Resource{
+		Create: resourceKeycloakOpenidClientOptionalScopesCreate,
+		Read:   resourceKeycloakOpenidClientOptionalScopesRead,
+		Delete: resourceKeycloakOpenidClientOptionalScopesDelete,
+		Update: resourceKeycloakOpenidClientOptionalScopesUpdate,
+		Schema: map[string]*schema.Schema{
+			"realm_id": {
+				Type:     schema.TypeString,
+				Required: true,
+				ForceNew: true,
+			},
+			"client_id": {
+				Type:     schema.TypeString,
+				Required: true,
+				ForceNew: true,
+			},
+			"optional_scopes": {
+				Type:     schema.TypeSet,
+				Elem:     &schema.Schema{Type: schema.TypeString},
+				Required: true,
+				Set:      schema.HashString,
+			},
+		},
+	}
+}
+
+func resourceKeycloakOpenidClientOptionalScopesCreate(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+	realmId := data.Get("realm_id").(string)
+	clientId := data.Get("client_id").(string)
+	optionalScopes := data.Get("optional_scopes").(*schema.Set)
+
+	err := keycloakClient.AttachOpenidClientOptionalScopes(realmId, clientId, interfaceSliceToStringSlice(optionalScopes.List()))
+	if err != nil {
+		return err
+	}
+
+	data.SetId(openidClientOptionalScopesId(realmId, clientId))
+
+	return resourceKeycloakOpenidClientOptionalScopesRead(data, meta)
+}
+
+func openidClientOptionalScopesId(realmId string, clientId string) string {
+	return fmt.Sprintf("%s/%s", realmId, clientId)
+}
+
+func resourceKeycloakOpenidClientOptionalScopesRead(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+
+	realmId := data.Get("realm_id").(string)
+	clientId := data.Get("client_id").(string)
+
+	clientScopes, err := keycloakClient.GetOpenidClientOptionalScopes(realmId, clientId)
+	if err != nil {
+		return err
+	}
+
+	var optionalScopes []string
+	for _, clientScope := range clientScopes {
+		optionalScopes = append(optionalScopes, clientScope.Name)
+	}
+
+	data.Set("optional_scopes", optionalScopes)
+	data.SetId(openidClientOptionalScopesId(realmId, clientId))
+
+	return nil
+}
+
+func resourceKeycloakOpenidClientOptionalScopesUpdate(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+
+	realmId := data.Get("realm_id").(string)
+	clientId := data.Get("client_id").(string)
+	tfOpenidClientOptionalScopes := data.Get("optional_scopes").(*schema.Set)
+
+	keycloakOpenidClientOptionalScopes, err := keycloakClient.GetOpenidClientOptionalScopes(realmId, clientId)
+	if err != nil {
+		return err
+	}
+
+	var openidClientOptionalScopesToDetach []string
+	for _, keycloakOpenidClientOptionalScope := range keycloakOpenidClientOptionalScopes {
+		// if this scope is attached in keycloak and tf state, no update is required
+		// remove it from the set so we can look at scopes that need to be attached later
+		if tfOpenidClientOptionalScopes.Contains(keycloakOpenidClientOptionalScope.Name) {
+			tfOpenidClientOptionalScopes.Remove(keycloakOpenidClientOptionalScope.Name)
+		} else {
+			// if this scope is attached in keycloak but not in tf state, add them to a slice containing all scopes to detach
+			openidClientOptionalScopesToDetach = append(openidClientOptionalScopesToDetach, keycloakOpenidClientOptionalScope.Name)
+		}
+	}
+
+	// detach scopes that aren't in tf state
+	err = keycloakClient.DetachOpenidClientOptionalScopes(realmId, clientId, openidClientOptionalScopesToDetach)
+	if err != nil {
+		return err
+	}
+
+	// attach scopes that exist in tf state but not in keycloak
+	err = keycloakClient.AttachOpenidClientOptionalScopes(realmId, clientId, interfaceSliceToStringSlice(tfOpenidClientOptionalScopes.List()))
+	if err != nil {
+		return err
+	}
+
+	data.SetId(openidClientOptionalScopesId(realmId, clientId))
+
+	return resourceKeycloakOpenidClientOptionalScopesRead(data, meta)
+}
+
+func resourceKeycloakOpenidClientOptionalScopesDelete(data *schema.ResourceData, meta interface{}) error {
+	keycloakClient := meta.(*keycloak.KeycloakClient)
+
+	realmId := data.Get("realm_id").(string)
+	clientId := data.Get("client_id").(string)
+	optionalScopes := data.Get("optional_scopes").(*schema.Set)
+
+	return keycloakClient.DetachOpenidClientOptionalScopes(realmId, clientId, interfaceSliceToStringSlice(optionalScopes.List()))
+}
diff --git a/provider/provider.go b/provider/provider.go
index c8e377b..2c99f18 100644
--- a/provider/provider.go
+++ b/provider/provider.go
@@ -26,6 +26,7 @@ func KeycloakProvider() *schema.Provider {
 			"keycloak_openid_full_name_protocol_mapper":        resourceKeycloakOpenIdFullNameProtocolMapper(),
 			"keycloak_openid_hardcoded_claim_protocol_mapper":  resourceKeycloakOpenIdHardcodedClaimProtocolMapper(),
 			"keycloak_openid_client_default_scopes":            resourceKeycloakOpenidClientDefaultScopes(),
+			"keycloak_openid_client_optional_scopes":           resourceKeycloakOpenidClientOptionalScopes(),
 			"keycloak_saml_client":                             resourceKeycloakSamlClient(),
 			"keycloak_saml_user_attribute_protocol_mapper":     resourceKeycloakSamlUserAttributeProtocolMapper(),
 			"keycloak_saml_user_property_protocol_mapper":      resourceKeycloakSamlUserPropertyProtocolMapper(),


Test patch:
diff --git a/provider/keycloak_openid_client_default_scopes_test.go b/provider/keycloak_openid_client_default_scopes_test.go
index 0d20e76..3358743 100644
--- a/provider/keycloak_openid_client_default_scopes_test.go
+++ b/provider/keycloak_openid_client_default_scopes_test.go
@@ -11,18 +11,15 @@ import (
 	"testing"
 )
 
+// All openid clients in Keycloak will automatically have these scopes listed as "default client scopes".
+var preAssignedDefaultClientScopes = []string{"profile", "email", "web-origins", "roles"}
+
 func TestAccKeycloakOpenidClientDefaultScopes_basic(t *testing.T) {
 	realm := "terraform-realm-" + acctest.RandString(10)
 	client := "terraform-client-" + acctest.RandString(10)
 	clientScope := "terraform-client-scope-" + acctest.RandString(10)
 
-	clientScopes := []string{
-		"profile",
-		"email",
-		"web-origins",
-		"roles",
-		clientScope,
-	}
+	clientScopes := append(preAssignedDefaultClientScopes, clientScope)
 
 	resource.Test(t, resource.TestCase{
 		Providers: testAccProviders,
@@ -48,13 +45,7 @@ func TestAccKeycloakOpenidClientDefaultScopes_updateClientForceNew(t *testing.T)
 	clientTwo := "terraform-client-" + acctest.RandString(10)
 	clientScope := "terraform-client-scope-" + acctest.RandString(10)
 
-	clientScopes := []string{
-		"profile",
-		"email",
-		"web-origins",
-		"roles",
-		clientScope,
-	}
+	clientScopes := append(preAssignedDefaultClientScopes, clientScope)
 
 	resource.Test(t, resource.TestCase{
 		Providers: testAccProviders,
@@ -77,13 +68,7 @@ func TestAccKeycloakOpenidClientDefaultScopes_updateInPlace(t *testing.T) {
 	client := "terraform-client-" + acctest.RandString(10)
 	clientScope := "terraform-client-scope-" + acctest.RandString(10)
 
-	allClientScopes := []string{
-		"profile",
-		"email",
-		"web-origins",
-		"roles",
-		clientScope,
-	}
+	allClientScopes := append(preAssignedDefaultClientScopes, clientScope)
 
 	clientScopeToRemove := allClientScopes[acctest.RandIntRange(0, 2)]
 	var subsetOfClientScopes []string
@@ -154,15 +139,11 @@ func TestAccKeycloakOpenidClientDefaultScopes_validateClientAccessType(t *testin
 func TestAccKeycloakOpenidClientDefaultScopes_authoritativeAdd(t *testing.T) {
 	realm := "terraform-realm-" + acctest.RandString(10)
 	client := "terraform-client-" + acctest.RandString(10)
-	clientScopes := []string{
-		"profile",
-		"email",
-		"web-origins",
-		"roles",
-		"terraform-client-scope-" + acctest.RandString(10),
-		"terraform-client-scope-" + acctest.RandString(10),
-		"terraform-client-scope-" + acctest.RandString(10),
-	}
+	clientScopes := append(preAssignedDefaultClientScopes,
+		"terraform-client-scope-"+acctest.RandString(10),
+		"terraform-client-scope-"+acctest.RandString(10),
+		"terraform-client-scope-"+acctest.RandString(10),
+	)
 
 	resource.Test(t, resource.TestCase{
 		Providers: testAccProviders,
@@ -199,18 +180,12 @@ func TestAccKeycloakOpenidClientDefaultScopes_authoritativeRemove(t *testing.T)
 	realm := "terraform-realm-" + acctest.RandString(10)
 	client := "terraform-client-" + acctest.RandString(10)
 
-	clientScopesAttachedByDefault := []string{
-		"profile",
-		"email",
-		"web-origins",
-		"roles",
-	}
 	randomClientScopes := []string{
 		"terraform-client-scope-" + acctest.RandString(10),
 		"terraform-client-scope-" + acctest.RandString(10),
 		"terraform-client-scope-" + acctest.RandString(10),
 	}
-	allClientScopes := append(clientScopesAttachedByDefault, randomClientScopes...)
+	allClientScopes := append(preAssignedDefaultClientScopes, randomClientScopes...)
 
 	clientToManuallyAttach := randomClientScopes[acctest.RandIntRange(0, len(randomClientScopes)-1)]
 	var attachedClientScopes []string
@@ -245,7 +220,7 @@ func TestAccKeycloakOpenidClientDefaultScopes_authoritativeRemove(t *testing.T)
 				Config: testKeycloakOpenidClientDefaultScopes_multipleClientScopes(realm, client, allClientScopes, attachedClientScopes),
 				Check: resource.ComposeTestCheckFunc(
 					testAccCheckKeycloakOpenidClientHasDefaultScopes("keycloak_openid_client_default_scopes.default_scopes", attachedClientScopes),
-					testAccCheckKeycloakOpenidClientScopeIsNotAttached("keycloak_openid_client_default_scopes.default_scopes", clientToManuallyAttach),
+					testAccCheckKeycloakOpenidClientDefaultScopeIsNotAttached("keycloak_openid_client_default_scopes.default_scopes", clientToManuallyAttach),
 				),
 			},
 		},
@@ -258,13 +233,7 @@ func TestAccKeycloakOpenidClientDefaultScopes_noImportNeeded(t *testing.T) {
 	client := "terraform-client-" + acctest.RandString(10)
 	clientScope := "terraform-client-scope-" + acctest.RandString(10)
 
-	clientScopes := []string{
-		"profile",
-		"email",
-		"web-origins",
-		"roles",
-		clientScope,
-	}
+	clientScopes := append(preAssignedDefaultClientScopes, clientScope)
 
 	resource.Test(t, resource.TestCase{
 		Providers: testAccProviders,
@@ -272,7 +241,7 @@ func TestAccKeycloakOpenidClientDefaultScopes_noImportNeeded(t *testing.T) {
 		Steps: []resource.TestStep{
 			{
 				Config: testKeycloakOpenidClientDefaultScopes_noDefaultScopes(realm, client, clientScope),
-				Check:  testAccCheckKeycloakOpenidClientScopeIsNotAttached("keycloak_openid_client.client", clientScope),
+				Check:  testAccCheckKeycloakOpenidClientDefaultScopeIsNotAttached("keycloak_openid_client.client", clientScope),
 			},
 			{
 				PreConfig: func() {
@@ -295,11 +264,11 @@ func TestAccKeycloakOpenidClientDefaultScopes_noImportNeeded(t *testing.T) {
 	})
 }
 
-// by default, keycloak clients have the scopes "profile" and "email" attached.
-// if you create this resource with only one scope, it won't remove these two scopes,
-// because the creation of a new resource should not result in anything destructive.
-// thus, a following plan will not be empty, as terraform will think it needs to remove
-// these scopes, which is okay to do during an update
+// by default, keycloak clients have the default scopes "profile", "email", "roles",
+// and "web-origins" attached. if you create this resource with only one scope, it
+// won't remove these two scopes, because the creation of a new resource should not
+// result in anything destructive. thus, a following plan will not be empty, as terraform
+// will think it needs to remove these scopes, which is okay to do during an update
 func TestAccKeycloakOpenidClientDefaultScopes_profileAndEmailDefaultScopes(t *testing.T) {
 	realm := "terraform-realm-" + acctest.RandString(10)
 	client := "terraform-client-" + acctest.RandString(10)
@@ -311,13 +280,39 @@ func TestAccKeycloakOpenidClientDefaultScopes_profileAndEmailDefaultScopes(t *te
 		Steps: []resource.TestStep{
 			{
 				Config:             testKeycloakOpenidClientDefaultScopes_listOfScopes(realm, client, clientScope, []string{clientScope}),
-				Check:              testAccCheckKeycloakOpenidClientHasDefaultScopes("keycloak_openid_client.client", []string{"profile", "email", clientScope}),
+				Check:              testAccCheckKeycloakOpenidClientHasDefaultScopes("keycloak_openid_client.client", append(preAssignedDefaultClientScopes, clientScope)),
 				ExpectNonEmptyPlan: true,
 			},
 		},
 	})
 }
 
+// Keycloak throws a 500 if you attempt to attach an optional scope that is already attached as an optional scope
+func TestAccKeycloakOpenidClientDefaultScopes_validateDuplicateScopeAssignment(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	client := "terraform-client-" + acctest.RandString(10)
+	clientScope := "terraform-client-scope-" + acctest.RandString(10)
+
+	optionalClientScopes := append(preAssignedOptionalClientScopes, clientScope)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			// attach optional scopes, including the custom scope
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_basic(realm, client, clientScope),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", optionalClientScopes),
+			},
+			// attach default scopes with the custom scope, expect an error since it is already in use
+			{
+				Config:      testKeycloakOpenidClientDefaultScopes_duplicateScopeAssignment(realm, client, clientScope),
+				ExpectError: regexp.MustCompile("validation error: scope .+ is already attached to client as an optional scope"),
+			},
+		},
+	})
+}
+
 func getDefaultClientScopesFromState(resourceName string, s *terraform.State) ([]*keycloak.OpenidClientScope, error) {
 	keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
 
@@ -385,7 +380,7 @@ func testAccCheckKeycloakOpenidClientHasNoDefaultScopes(resourceName string) res
 	}
 }
 
-func testAccCheckKeycloakOpenidClientScopeIsNotAttached(resourceName, clientScope string) resource.TestCheckFunc {
+func testAccCheckKeycloakOpenidClientDefaultScopeIsNotAttached(resourceName, clientScope string) resource.TestCheckFunc {
 	return func(s *terraform.State) error {
 		keycloakDefaultClientScopes, err := getDefaultClientScopesFromState(resourceName, s)
 		if err != nil {
@@ -427,12 +422,12 @@ resource "keycloak_openid_client_default_scopes" "default_scopes" {
 	realm_id       = "${keycloak_realm.realm.id}"
 	client_id      = "${keycloak_openid_client.client.id}"
 	default_scopes = [
-        "profile",
-        "email",
+		"profile",
+		"email",
 		"roles",
 		"web-origins",
-        "${keycloak_openid_client_scope.client_scope.name}"
-    ]
+		"${keycloak_openid_client_scope.client_scope.name}"
+	]
 }
 	`, realm, client, clientScope)
 }
@@ -508,12 +503,12 @@ resource "keycloak_openid_client_default_scopes" "default_scopes" {
 	realm_id       = "${keycloak_realm.realm.id}"
 	client_id      = "%s"
 	default_scopes = [
-        "profile",
-        "email",
+		"profile",
+		"email",
 		"roles",
 		"web-origins",
-        "${keycloak_openid_client_scope.client_scope.name}"
-    ]
+		"${keycloak_openid_client_scope.client_scope.name}"
+	]
 }
 	`, realm, clientScope, client)
 }
@@ -543,12 +538,12 @@ resource "keycloak_openid_client_default_scopes" "default_scopes" {
 	realm_id       = "${keycloak_realm.realm.id}"
 	client_id      = "${keycloak_openid_client.client.id}"
 	default_scopes = [
-        "profile",
-        "email",
+		"profile",
+		"email",
 		"roles",
 		"web-origins",
-        "${keycloak_openid_client_scope.client_scope.name}"
-    ]
+		"${keycloak_openid_client_scope.client_scope.name}"
+	]
 }
 	`, realm, client, clientScope)
 }
@@ -597,3 +592,21 @@ resource "keycloak_openid_client_default_scopes" "default_scopes" {
 }
 	`, realm, client, clientScopeResources.String(), arrayOfStringsForTerraformResource(attachedClientScopesInterpolated))
 }
+
+func testKeycloakOpenidClientDefaultScopes_duplicateScopeAssignment(realm, client, clientScope string) string {
+	return fmt.Sprintf(`
+%s
+
+resource "keycloak_openid_client_default_scopes" "default_scopes" {
+	realm_id       = "${keycloak_realm.realm.id}"
+	client_id      = "${keycloak_openid_client.client.id}"
+	default_scopes = [
+		"profile",
+		"email",
+		"roles",
+		"web-origins",
+		"${keycloak_openid_client_scope.client_scope.name}"
+	]
+}
+	`, testKeycloakOpenidClientOptionalScopes_basic(realm, client, clientScope))
+}
diff --git a/provider/keycloak_openid_client_optional_scopes_test.go b/provider/keycloak_openid_client_optional_scopes_test.go
new file mode 100644
index 0000000..f7db5f2
--- /dev/null
+++ b/provider/keycloak_openid_client_optional_scopes_test.go
@@ -0,0 +1,609 @@
+package provider
+
+import (
+	"fmt"
+	"github.com/hashicorp/terraform/helper/acctest"
+	"github.com/hashicorp/terraform/helper/resource"
+	"github.com/hashicorp/terraform/terraform"
+	"github.com/mrparkers/terraform-provider-keycloak/keycloak"
+	"regexp"
+	"strings"
+	"testing"
+)
+
+// All openid clients in Keycloak will automatically have these scopes listed as "optional client scopes".
+var preAssignedOptionalClientScopes = []string{"address", "phone", "offline_access"}
+
+func TestAccKeycloakOpenidClientOptionalScopes_basic(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	client := "terraform-client-" + acctest.RandString(10)
+	clientScope := "terraform-client-scope-" + acctest.RandString(10)
+
+	clientScopes := append(preAssignedOptionalClientScopes, clientScope)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_basic(realm, client, clientScope),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", clientScopes),
+			},
+			// we need a separate test step for destroy instead of using CheckDestroy because this resource is implicitly
+			// destroyed at the end of each test via destroying clients
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_noOptionalScopes(realm, client, clientScope),
+				Check:  testAccCheckKeycloakOpenidClientHasNoOptionalScopes("keycloak_openid_client.client"),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenidClientOptionalScopes_updateClientForceNew(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	clientOne := "terraform-client-" + acctest.RandString(10)
+	clientTwo := "terraform-client-" + acctest.RandString(10)
+	clientScope := "terraform-client-scope-" + acctest.RandString(10)
+
+	clientScopes := append(preAssignedOptionalClientScopes, clientScope)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_basic(realm, clientOne, clientScope),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", clientScopes),
+			},
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_basic(realm, clientTwo, clientScope),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", clientScopes),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenidClientOptionalScopes_updateInPlace(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	client := "terraform-client-" + acctest.RandString(10)
+	clientScope := "terraform-client-scope-" + acctest.RandString(10)
+
+	allClientScopes := append(preAssignedOptionalClientScopes, clientScope)
+
+	clientScopeToRemove := allClientScopes[acctest.RandIntRange(0, 2)]
+	var subsetOfClientScopes []string
+	for _, cs := range allClientScopes {
+		if cs != clientScopeToRemove {
+			subsetOfClientScopes = append(subsetOfClientScopes, cs)
+		}
+	}
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			// init
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_listOfScopes(realm, client, clientScope, allClientScopes),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", allClientScopes),
+			},
+			// remove
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_listOfScopes(realm, client, clientScope, subsetOfClientScopes),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", subsetOfClientScopes),
+			},
+			// add
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_listOfScopes(realm, client, clientScope, allClientScopes),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", allClientScopes),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenidClientOptionalScopes_validateClientDoesNotExist(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	client := "terraform-client-" + acctest.RandString(10)
+	clientScope := "terraform-client-scope-" + acctest.RandString(10)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config:      testKeycloakOpenidClientOptionalScopes_validationNoClient(realm, client, clientScope),
+				ExpectError: regexp.MustCompile("validation error: client with id .+ does not exist"),
+			},
+		},
+	})
+}
+
+func TestAccKeycloakOpenidClientOptionalScopes_validateClientAccessType(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	client := "terraform-client-" + acctest.RandString(10)
+	clientScope := "terraform-client-scope-" + acctest.RandString(10)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config:      testKeycloakOpenidClientOptionalScopes_validationBearerOnlyClient(realm, client, clientScope),
+				ExpectError: regexp.MustCompile("validation error: client with id .+ uses access type BEARER-ONLY which does not use scopes"),
+			},
+		},
+	})
+}
+
+// if a optional client scope is manually detached from a client with optional scopes controlled by this resource, terraform should add it again
+func TestAccKeycloakOpenidClientOptionalScopes_authoritativeAdd(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	client := "terraform-client-" + acctest.RandString(10)
+	clientScopes := append(preAssignedOptionalClientScopes,
+		"terraform-client-scope-"+acctest.RandString(10),
+		"terraform-client-scope-"+acctest.RandString(10),
+		"terraform-client-scope-"+acctest.RandString(10),
+	)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_multipleClientScopes(realm, client, clientScopes, clientScopes),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", clientScopes),
+			},
+			{
+				PreConfig: func() {
+					keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
+
+					client, err := keycloakClient.GetOpenidClientByClientId(realm, client)
+					if err != nil {
+						t.Fatal(err)
+					}
+
+					clientToManuallyDetach := clientScopes[acctest.RandIntRange(0, len(clientScopes)-1)]
+					err = keycloakClient.DetachOpenidClientOptionalScopes(realm, client.Id, []string{clientToManuallyDetach})
+					if err != nil {
+						t.Fatal(err)
+					}
+				},
+				Config: testKeycloakOpenidClientOptionalScopes_multipleClientScopes(realm, client, clientScopes, clientScopes),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", clientScopes),
+			},
+		},
+	})
+}
+
+// if an optional client scope is manually attached to a client with optional scopes controlled by this resource, terraform should detach it
+func TestAccKeycloakOpenidClientOptionalScopes_authoritativeRemove(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	client := "terraform-client-" + acctest.RandString(10)
+
+	randomClientScopes := []string{
+		"terraform-client-scope-" + acctest.RandString(10),
+		"terraform-client-scope-" + acctest.RandString(10),
+		"terraform-client-scope-" + acctest.RandString(10),
+	}
+	allClientScopes := append(preAssignedOptionalClientScopes, randomClientScopes...)
+
+	clientToManuallyAttach := randomClientScopes[acctest.RandIntRange(0, len(randomClientScopes)-1)]
+	var attachedClientScopes []string
+	for _, clientScope := range allClientScopes {
+		if clientScope != clientToManuallyAttach {
+			attachedClientScopes = append(attachedClientScopes, clientScope)
+		}
+	}
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_multipleClientScopes(realm, client, allClientScopes, attachedClientScopes),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", attachedClientScopes),
+			},
+			{
+				PreConfig: func() {
+					keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
+
+					client, err := keycloakClient.GetOpenidClientByClientId(realm, client)
+					if err != nil {
+						t.Fatal(err)
+					}
+
+					err = keycloakClient.AttachOpenidClientOptionalScopes(realm, client.Id, []string{clientToManuallyAttach})
+					if err != nil {
+						t.Fatal(err)
+					}
+				},
+				Config: testKeycloakOpenidClientOptionalScopes_multipleClientScopes(realm, client, allClientScopes, attachedClientScopes),
+				Check: resource.ComposeTestCheckFunc(
+					testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", attachedClientScopes),
+					testAccCheckKeycloakOpenidClientOptionalScopeIsNotAttached("keycloak_openid_client_optional_scopes.optional_scopes", clientToManuallyAttach),
+				),
+			},
+		},
+	})
+}
+
+// this resource doesn't support import because it can be created even if the desired state already exists in keycloak
+func TestAccKeycloakOpenidClientOptionalScopes_noImportNeeded(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	client := "terraform-client-" + acctest.RandString(10)
+	clientScope := "terraform-client-scope-" + acctest.RandString(10)
+
+	clientScopes := append(preAssignedOptionalClientScopes, clientScope)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenidClientOptionalScopes_noOptionalScopes(realm, client, clientScope),
+				Check:  testAccCheckKeycloakOpenidClientOptionalScopeIsNotAttached("keycloak_openid_client.client", clientScope),
+			},
+			{
+				PreConfig: func() {
+					keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
+
+					openidClient, err := keycloakClient.GetOpenidClientByClientId(realm, client)
+					if err != nil {
+						t.Fatal(err)
+					}
+
+					err = keycloakClient.AttachOpenidClientOptionalScopes(realm, openidClient.Id, clientScopes)
+					if err != nil {
+						t.Fatal(err)
+					}
+				},
+				Config: testKeycloakOpenidClientOptionalScopes_basic(realm, client, clientScope),
+				Check:  testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client_optional_scopes.optional_scopes", clientScopes),
+			},
+		},
+	})
+}
+
+// by optional, keycloak clients have the optional scopes "address", "phone" and
+// "offline_access" attached. if you create this resource with only one scope, it
+// won't remove these two scopes, because the creation of a new resource should
+// not result in anything destructive. thus, a following plan will not be empty,
+// as terraform will think it needs to remove these scopes, which is okay to do
+// during an update
+func TestAccKeycloakOpenidClientOptionalScopes_profileAndEmailOptionalScopes(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	client := "terraform-client-" + acctest.RandString(10)
+	clientScope := "terraform-client-scope-" + acctest.RandString(10)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			{
+				Config:             testKeycloakOpenidClientOptionalScopes_listOfScopes(realm, client, clientScope, []string{clientScope}),
+				Check:              testAccCheckKeycloakOpenidClientHasOptionalScopes("keycloak_openid_client.client", append(preAssignedOptionalClientScopes, clientScope)),
+				ExpectNonEmptyPlan: true,
+			},
+		},
+	})
+}
+
+// Keycloak throws a 500 if you attempt to attach an optional scope that is already attached as a default scope
+func TestAccKeycloakOpenidClientOptionalScopes_validateDuplicateScopeAssignment(t *testing.T) {
+	realm := "terraform-realm-" + acctest.RandString(10)
+	client := "terraform-client-" + acctest.RandString(10)
+	clientScope := "terraform-client-scope-" + acctest.RandString(10)
+
+	defaultClientScopes := append(preAssignedDefaultClientScopes, clientScope)
+
+	resource.Test(t, resource.TestCase{
+		Providers: testAccProviders,
+		PreCheck:  func() { testAccPreCheck(t) },
+		Steps: []resource.TestStep{
+			// attach default scopes, including the custom scope
+			{
+				Config: testKeycloakOpenidClientDefaultScopes_basic(realm, client, clientScope),
+				Check:  testAccCheckKeycloakOpenidClientHasDefaultScopes("keycloak_openid_client_default_scopes.default_scopes", defaultClientScopes),
+			},
+			// attach optional scopes with the custom scope, expect an error since it is already in use
+			{
+				Config:      testKeycloakOpenidClientOptionalScopes_duplicateScopeAssignment(realm, client, clientScope),
+				ExpectError: regexp.MustCompile("validation error: scope .+ is already attached to client as a default scope"),
+			},
+		},
+	})
+}
+
+func getOptionalClientScopesFromState(resourceName string, s *terraform.State) ([]*keycloak.OpenidClientScope, error) {
+	keycloakClient := testAccProvider.Meta().(*keycloak.KeycloakClient)
+
+	rs, ok := s.RootModule().Resources[resourceName]
+	if !ok {
+		return nil, fmt.Errorf("resource not found: %s", resourceName)
+	}
+
+	realm := rs.Primary.Attributes["realm_id"]
+
+	var client string
+	if strings.HasPrefix(resourceName, "keycloak_openid_client_optional_scopes") {
+		client = rs.Primary.Attributes["client_id"]
+	} else {
+		client = rs.Primary.ID
+	}
+
+	keycloakOptionalClientScopes, err := keycloakClient.GetOpenidClientOptionalScopes(realm, client)
+	if err != nil {
+		return nil, err
+	}
+
+	return keycloakOptionalClientScopes, nil
+}
+
+func testAccCheckKeycloakOpenidClientHasOptionalScopes(resourceName string, tfOptionalClientScopes []string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		keycloakOptionalClientScopes, err := getOptionalClientScopesFromState(resourceName, s)
+		if err != nil {
+			return err
+		}
+
+		for _, tfOptionalClientScope := range tfOptionalClientScopes {
+			found := false
+
+			for _, keycloakOptionalScope := range keycloakOptionalClientScopes {
+				if keycloakOptionalScope.Name == tfOptionalClientScope {
+					found = true
+
+					break
+				}
+			}
+
+			if !found {
+				return fmt.Errorf("optional scope %s is not assigned to client", tfOptionalClientScope)
+			}
+		}
+
+		return nil
+	}
+}
+
+func testAccCheckKeycloakOpenidClientHasNoOptionalScopes(resourceName string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		keycloakOptionalClientScopes, err := getOptionalClientScopesFromState(resourceName, s)
+		if err != nil {
+			return err
+		}
+
+		if numberOfOptionalScopes := len(keycloakOptionalClientScopes); numberOfOptionalScopes != 0 {
+			return fmt.Errorf("expected client to have no assigned optional scopes, but it has %d", numberOfOptionalScopes)
+		}
+
+		return nil
+	}
+}
+
+func testAccCheckKeycloakOpenidClientOptionalScopeIsNotAttached(resourceName, clientScope string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		keycloakOptionalClientScopes, err := getOptionalClientScopesFromState(resourceName, s)
+		if err != nil {
+			return err
+		}
+
+		for _, keycloakOptionalClientScope := range keycloakOptionalClientScopes {
+			if keycloakOptionalClientScope.Name == clientScope {
+				return fmt.Errorf("expected client scope with name %s to not be attached to client", clientScope)
+			}
+		}
+
+		return nil
+	}
+}
+
+func testKeycloakOpenidClientOptionalScopes_basic(realm, client, clientScope string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "client" {
+	client_id   = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+	access_type = "PUBLIC"
+
+	valid_redirect_uris = ["foo"]
+}
+
+resource "keycloak_openid_client_scope" "client_scope" {
+	name        = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+
+	description = "test description"
+}
+
+resource "keycloak_openid_client_optional_scopes" "optional_scopes" {
+	realm_id       = "${keycloak_realm.realm.id}"
+	client_id      = "${keycloak_openid_client.client.id}"
+	optional_scopes = [
+		"address",
+		"phone",
+		"offline_access",
+		"${keycloak_openid_client_scope.client_scope.name}"
+	]
+}
+	`, realm, client, clientScope)
+}
+
+func testKeycloakOpenidClientOptionalScopes_noOptionalScopes(realm, client, clientScope string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "client" {
+	client_id   = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+	access_type = "PUBLIC"
+
+	valid_redirect_uris = ["foo"]
+}
+
+resource "keycloak_openid_client_scope" "client_scope" {
+	name        = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+
+	description = "test description"
+}
+	`, realm, client, clientScope)
+}
+
+func testKeycloakOpenidClientOptionalScopes_listOfScopes(realm, client, clientScope string, listOfOptionalScopes []string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "client" {
+	client_id   = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+	access_type = "PUBLIC"
+
+	valid_redirect_uris = ["foo"]
+}
+
+resource "keycloak_openid_client_scope" "client_scope" {
+	name        = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+
+	description = "test description"
+}
+
+resource "keycloak_openid_client_optional_scopes" "optional_scopes" {
+	realm_id       = "${keycloak_realm.realm.id}"
+	client_id      = "${keycloak_openid_client.client.id}"
+	optional_scopes = %s
+
+	depends_on = ["keycloak_openid_client_scope.client_scope"]
+}
+	`, realm, client, clientScope, arrayOfStringsForTerraformResource(listOfOptionalScopes))
+}
+
+func testKeycloakOpenidClientOptionalScopes_validationNoClient(realm, client, clientScope string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client_scope" "client_scope" {
+	name        = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+
+	description = "test description"
+}
+
+resource "keycloak_openid_client_optional_scopes" "optional_scopes" {
+	realm_id       = "${keycloak_realm.realm.id}"
+	client_id      = "%s"
+	optional_scopes = [
+		"address",
+		"phone",
+		"offline_access",
+		"${keycloak_openid_client_scope.client_scope.name}"
+	]
+}
+	`, realm, clientScope, client)
+}
+
+func testKeycloakOpenidClientOptionalScopes_validationBearerOnlyClient(realm, client, clientScope string) string {
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "client" {
+	client_id   = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+	access_type = "BEARER-ONLY"
+
+	valid_redirect_uris = ["foo"]
+}
+
+resource "keycloak_openid_client_scope" "client_scope" {
+	name        = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+
+	description = "test description"
+}
+
+resource "keycloak_openid_client_optional_scopes" "optional_scopes" {
+	realm_id       = "${keycloak_realm.realm.id}"
+	client_id      = "${keycloak_openid_client.client.id}"
+	optional_scopes = [
+		"address",
+		"phone",
+		"offline_access",
+		"${keycloak_openid_client_scope.client_scope.name}"
+	]
+}
+	`, realm, client, clientScope)
+}
+
+func testKeycloakOpenidClientOptionalScopes_multipleClientScopes(realm, client string, allClientScopes, attachedClientScopes []string) string {
+	var clientScopeResources strings.Builder
+	for _, clientScope := range allClientScopes {
+		if strings.HasPrefix(clientScope, "terraform") {
+			clientScopeResources.WriteString(fmt.Sprintf(`
+resource "keycloak_openid_client_scope" "client_scope_%s" {
+	name        = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+}
+		`, clientScope, clientScope))
+		}
+	}
+
+	var attachedClientScopesInterpolated []string
+	for _, attachedClientScope := range attachedClientScopes {
+		if strings.HasPrefix(attachedClientScope, "terraform") {
+			attachedClientScopesInterpolated = append(attachedClientScopesInterpolated, fmt.Sprintf("${keycloak_openid_client_scope.client_scope_%s.name}", attachedClientScope))
+		} else {
+			attachedClientScopesInterpolated = append(attachedClientScopesInterpolated, attachedClientScope)
+		}
+	}
+
+	return fmt.Sprintf(`
+resource "keycloak_realm" "realm" {
+	realm = "%s"
+}
+
+resource "keycloak_openid_client" "client" {
+	client_id   = "%s"
+	realm_id    = "${keycloak_realm.realm.id}"
+	access_type = "PUBLIC"
+
+	valid_redirect_uris = ["foo"]
+}
+
+%s
+
+resource "keycloak_openid_client_optional_scopes" "optional_scopes" {
+	realm_id       = "${keycloak_realm.realm.id}"
+	client_id      = "${keycloak_openid_client.client.id}"
+	optional_scopes = %s
+}
+	`, realm, client, clientScopeResources.String(), arrayOfStringsForTerraformResource(attachedClientScopesInterpolated))
+}
+
+func testKeycloakOpenidClientOptionalScopes_duplicateScopeAssignment(realm, client, clientScope string) string {
+	return fmt.Sprintf(`
+%s
+
+resource "keycloak_openid_client_optional_scopes" "optional_scopes" {
+	realm_id       = "${keycloak_realm.realm.id}"
+	client_id      = "${keycloak_openid_client.client.id}"
+	optional_scopes = [
+		"address",
+		"phone",
+		"offline_access",
+		"${keycloak_openid_client_scope.client_scope.name}"
+	]
+}
+	`, testKeycloakOpenidClientDefaultScopes_basic(realm, client, clientScope))
+}