Repository: mrparkers/terraform-provider-keycloak
Pull request #415 (2020-10-26 16:24:29)

Add client session idle and max timeouts for openid_client resource
Add `client.offline.session.idle.timeout`, `client.offline.session.max.lifespan`, `client.session.idle.timeout` and `client.session.max.lifespan` openid client attributes.


Patch:
diff --git a/docs-old/resources/keycloak_ldap_group_mapper.md b/docs-old/resources/keycloak_ldap_group_mapper.md
index 3ed0ea3..1b3efb4 100644
--- a/docs-old/resources/keycloak_ldap_group_mapper.md
+++ b/docs-old/resources/keycloak_ldap_group_mapper.md
@@ -65,7 +65,7 @@ The following arguments are supported:
 - `membership_attribute_type` - (Optional) Can be one of `DN` or `UID`. Defaults to `DN`.
 - `membership_user_ldap_attribute` - (Required) The name of the LDAP attribute on a user that is used for membership mappings.
 - `groups_ldap_filter` - (Optional) When specified, adds an additional custom filter to be used when querying for groups. Must start with `(` and end with `)`.
-- `mode` - (Optional) Can be one of `READ_ONLY` or `LDAP_ONLY`. Defaults to `READ_ONLY`.
+- `mode` - (Optional) Can be one of `READ_ONLY`, `LDAP_ONLY` or `IMPORT`. Defaults to `READ_ONLY`.
 - `user_roles_retrieve_strategy` - (Optional) Can be one of `LOAD_GROUPS_BY_MEMBER_ATTRIBUTE`, `GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE`, or `LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY`. Defaults to `LOAD_GROUPS_BY_MEMBER_ATTRIBUTE`.
 - `memberof_ldap_attribute` - (Optional) Specifies the name of the LDAP attribute on the LDAP user that contains the groups the user is a member of. Defaults to `memberOf`.
 - `mapped_group_attributes` - (Optional) Array of strings representing attributes on the LDAP group which will be mapped to attributes on the Keycloak group.
diff --git a/docs/resources/ldap_group_mapper.md b/docs/resources/ldap_group_mapper.md
index 5418afe..4393d12 100644
--- a/docs/resources/ldap_group_mapper.md
+++ b/docs/resources/ldap_group_mapper.md
@@ -66,7 +66,7 @@ resource "keycloak_ldap_group_mapper" "ldap_group_mapper" {
 - `membership_attribute_type` - (Optional) Can be one of `DN` or `UID`. Defaults to `DN`.
 - `membership_user_ldap_attribute` - (Required) The name of the LDAP attribute on a user that is used for membership mappings.
 - `groups_ldap_filter` - (Optional) When specified, adds an additional custom filter to be used when querying for groups. Must start with `(` and end with `)`.
-- `mode` - (Optional) Can be one of `READ_ONLY` or `LDAP_ONLY`. Defaults to `READ_ONLY`.
+- `mode` - (Optional) Can be one of `READ_ONLY`, `LDAP_ONLY` or `IMPORT`. Defaults to `READ_ONLY`.
 - `user_roles_retrieve_strategy` - (Optional) Can be one of `LOAD_GROUPS_BY_MEMBER_ATTRIBUTE`, `GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE`, or `LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY`. Defaults to `LOAD_GROUPS_BY_MEMBER_ATTRIBUTE`.
 - `memberof_ldap_attribute` - (Optional) Specifies the name of the LDAP attribute on the LDAP user that contains the groups the user is a member of. Defaults to `memberOf`.
 - `mapped_group_attributes` - (Optional) Array of strings representing attributes on the LDAP group which will be mapped to attributes on the Keycloak group.
diff --git a/docs/resources/openid_client.md b/docs/resources/openid_client.md
index 026a086..f2fb508 100644
--- a/docs/resources/openid_client.md
+++ b/docs/resources/openid_client.md
@@ -62,6 +62,10 @@ is set to `true`.
 - `pkce_code_challenge_method` - (Optional) The challenge method to use for Proof Key for Code Exchange. Can be either `plain` or `S256` or set to empty value ``.
 - `full_scope_allowed` - (Optional) Allow to include all roles mappings in the access token.
 - `access_token_lifespan` - (Optional) The amount of time in seconds before an access token expires. This will override the default for the realm.
+- `client_offline_session_idle_timeout` - (Optional) Time a client session is allowed to be idle before it expires. Tokens are invalidated when a client session is expired. If not set it uses the standard SSO Session Idle value.
+- `client_offline_session_max_lifespan` - (Optional) Max time before a client session is expired. Tokens are invalidated when a client session is expired. If not set, it uses the standard SSO Session Max value.
+- `client_session_idle_timeout` - (Optional) Time a client offline session is allowed to be idle before it expires. Offline tokens are invalidated when a client offline session is expired. If not set it uses the Offline Session Idle value.
+- `client_session_max_lifespan` - (Optional) Max time before a client offline session is expired. Offline tokens are invalidated when a client offline session is expired. If not set, it uses the Offline Session Max value.
 - `consent_required` - (Optional) When `true`, users have to consent to client access.
 - `authentication_flow_binding_overrides` - (Optional) Override realm authentication flow bindings
     - `browser_id` - (Optional) Browser flow id, (flow needs to exist)
@@ -70,6 +74,7 @@ is set to `true`.
 - `exclude_session_state_from_auth_response` - (Optional) When `true`, the parameter `session_state` will not be included in OpenID Connect Authentication Response.
 - `authorization` - (Optional) When this block is present, fine-grained authorization will be enabled for this client. The client's `access_type` must be `CONFIDENTIAL`, and `service_accounts_enabled` must be `true`. This block has the following arguments:
     - `policy_enforcement_mode` - (Required) Dictates how policies are enforced when evaluating authorization requests. Can be one of `ENFORCING`, `PERMISSIVE`, or `DISABLED`.
+    - `decision_strategy` - (Optional) Dictates how the policies associated with a given permission are evaluated and how a final decision is obtained. Could be one of `AFFIRMATIVE`, `CONSENSUS`, or `UNANIMOUS`. Applies to permissions.
     - `allow_remote_resource_management` - (Optional) When `true`, resources can be managed remotely by the resource server. Defaults to `false`.
     - `keep_defaults` - (Optional) When `true`, defaults set by Keycloak will be respected. Defaults to `false`.
 
diff --git a/keycloak/keycloak_client.go b/keycloak/keycloak_client.go
index cfc97d1..d914e72 100644
--- a/keycloak/keycloak_client.go
+++ b/keycloak/keycloak_client.go
@@ -2,7 +2,6 @@ package keycloak
 
 import (
 	"bytes"
-	"context"
 	"crypto/tls"
 	"crypto/x509"
 	"encoding/json"
@@ -21,7 +20,6 @@ import (
 )
 
 type KeycloakClient struct {
-	ctx               context.Context
 	baseUrl           string
 	realm             string
 	clientCredentials *ClientCredentials
@@ -47,8 +45,6 @@ const (
 )
 
 func NewKeycloakClient(url, basePath, clientId, clientSecret, realm, username, password string, initialLogin bool, clientTimeout int, caCert string, tlsInsecureSkipVerify bool, userAgent string) (*KeycloakClient, error) {
-	ctx := context.Background()
-
 	cookieJar, err := cookiejar.New(&cookiejar.Options{
 		PublicSuffixList: publicsuffix.List,
 	})
@@ -91,7 +87,6 @@ func NewKeycloakClient(url, basePath, clientId, clientSecret, realm, username, p
 	}
 
 	keycloakClient := KeycloakClient{
-		ctx:               ctx,
 		baseUrl:           url + basePath,
 		clientCredentials: clientCredentials,
 		httpClient:        httpClient,
@@ -116,7 +111,11 @@ func (keycloakClient *KeycloakClient) login() error {
 
 	log.Printf("[DEBUG] Login request: %s", accessTokenData.Encode())
 
-	accessTokenRequest, _ := http.NewRequestWithContext(keycloakClient.ctx, http.MethodPost, accessTokenUrl, strings.NewReader(accessTokenData.Encode()))
+	accessTokenRequest, err := http.NewRequest(http.MethodPost, accessTokenUrl, strings.NewReader(accessTokenData.Encode()))
+	if err != nil {
+		return err
+	}
+
 	accessTokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 
 	if keycloakClient.userAgent != "" {
@@ -157,7 +156,11 @@ func (keycloakClient *KeycloakClient) refresh() error {
 
 	log.Printf("[DEBUG] Refresh request: %s", refreshTokenData.Encode())
 
-	refreshTokenRequest, _ := http.NewRequestWithContext(keycloakClient.ctx, http.MethodPost, refreshTokenUrl, strings.NewReader(refreshTokenData.Encode()))
+	refreshTokenRequest, err := http.NewRequest(http.MethodPost, refreshTokenUrl, strings.NewReader(refreshTokenData.Encode()))
+	if err != nil {
+		return err
+	}
+
 	refreshTokenRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded")
 
 	if keycloakClient.userAgent != "" {
@@ -331,7 +334,7 @@ func (keycloakClient *KeycloakClient) get(path string, resource interface{}, par
 func (keycloakClient *KeycloakClient) getRaw(path string, params map[string]string) ([]byte, error) {
 	resourceUrl := keycloakClient.baseUrl + apiUrl + path
 
-	request, err := http.NewRequestWithContext(keycloakClient.ctx, http.MethodGet, resourceUrl, nil)
+	request, err := http.NewRequest(http.MethodGet, resourceUrl, nil)
 	if err != nil {
 		return nil, err
 	}
@@ -356,7 +359,7 @@ func (keycloakClient *KeycloakClient) post(path string, requestBody interface{})
 		return nil, "", err
 	}
 
-	request, err := http.NewRequestWithContext(keycloakClient.ctx, http.MethodPost, resourceUrl, bytes.NewReader(payload))
+	request, err := http.NewRequest(http.MethodPost, resourceUrl, bytes.NewReader(payload))
 	if err != nil {
 		return nil, "", err
 	}
@@ -374,7 +377,7 @@ func (keycloakClient *KeycloakClient) put(path string, requestBody interface{})
 		return err
 	}
 
-	request, err := http.NewRequestWithContext(keycloakClient.ctx, http.MethodPut, resourceUrl, bytes.NewReader(payload))
+	request, err := http.NewRequest(http.MethodPut, resourceUrl, bytes.NewReader(payload))
 	if err != nil {
 		return err
 	}
@@ -397,7 +400,7 @@ func (keycloakClient *KeycloakClient) delete(path string, requestBody interface{
 		body = bytes.NewReader(payload)
 	}
 
-	request, err := http.NewRequestWithContext(keycloakClient.ctx, http.MethodDelete, resourceUrl, body)
+	request, err := http.NewRequest(http.MethodDelete, resourceUrl, body)
 	if err != nil {
 		return err
 	}
diff --git a/keycloak/openid_client.go b/keycloak/openid_client.go
index aa8260b..ee68012 100644
--- a/keycloak/openid_client.go
+++ b/keycloak/openid_client.go
@@ -20,6 +20,7 @@ type OpenidClientSecret struct {
 
 type OpenidClientAuthorizationSettings struct {
 	PolicyEnforcementMode         string `json:"policyEnforcementMode,omitempty"`
+	DecisionStrategy              string `json:"decisionStrategy,omitempty"`
 	AllowRemoteResourceManagement bool   `json:"allowRemoteResourceManagement,omitempty"`
 	KeepDefaults                  bool   `json:"-"`
 }
@@ -58,6 +59,10 @@ type OpenidClientAttributes struct {
 	ExcludeSessionStateFromAuthResponse KeycloakBoolQuoted `json:"exclude.session.state.from.auth.response"`
 	AccessTokenLifespan                 string             `json:"access.token.lifespan"`
 	LoginTheme                          string             `json:"login_theme"`
+	ClientOfflineSessionIdleTimeout     string             `json:"client.offline.session.idle.timeout,omitempty"`
+	ClientOfflineSessionMaxLifespan     string             `json:"client.offline.session.max.lifespan,omitempty"`
+	ClientSessionIdleTimeout            string             `json:"client.session.idle.timeout,omitempty"`
+	ClientSessionMaxLifespan            string             `json:"client.session.max.lifespan,omitempty"`
 }
 
 type OpenidAuthenticationFlowBindingOverrides struct {
diff --git a/provider/data_source_keycloak_openid_client.go b/provider/data_source_keycloak_openid_client.go
index 3249d9a..36eadb5 100644
--- a/provider/data_source_keycloak_openid_client.go
+++ b/provider/data_source_keycloak_openid_client.go
@@ -108,6 +108,10 @@ func dataSourceKeycloakOpenidClient() *schema.Resource {
 							Type:     schema.TypeString,
 							Computed: true,
 						},
+						"decision_strategy": {
+							Type:     schema.TypeString,
+							Computed: true,
+						},
 						"allow_remote_resource_management": {
 							Type:     schema.TypeBool,
 							Computed: true,
diff --git a/provider/resource_keycloak_ldap_group_mapper.go b/provider/resource_keycloak_ldap_group_mapper.go
index e637e69..6376f04 100644
--- a/provider/resource_keycloak_ldap_group_mapper.go
+++ b/provider/resource_keycloak_ldap_group_mapper.go
@@ -8,7 +8,7 @@ import (
 )
 
 var (
-	keycloakLdapGroupMapperModes                       = []string{"READ_ONLY", "LDAP_ONLY"}
+	keycloakLdapGroupMapperModes                       = []string{"READ_ONLY", "LDAP_ONLY", "IMPORT"}
 	keycloakLdapGroupMapperMembershipAttributeTypes    = []string{"DN", "UID"}
 	keycloakLdapGroupMapperUserRolesRetrieveStrategies = []string{"LOAD_GROUPS_BY_MEMBER_ATTRIBUTE", "GET_GROUPS_FROM_USER_MEMBEROF_ATTRIBUTE", "LOAD_GROUPS_BY_MEMBER_ATTRIBUTE_RECURSIVELY"}
 )
diff --git a/provider/resource_keycloak_openid_client.go b/provider/resource_keycloak_openid_client.go
index 6296fc2..d62d628 100644
--- a/provider/resource_keycloak_openid_client.go
+++ b/provider/resource_keycloak_openid_client.go
@@ -11,9 +11,10 @@ import (
 )
 
 var (
-	keycloakOpenidClientAccessTypes                        = []string{"CONFIDENTIAL", "PUBLIC", "BEARER-ONLY"}
-	keycloakOpenidClientAuthorizationPolicyEnforcementMode = []string{"ENFORCING", "PERMISSIVE", "DISABLED"}
-	keycloakOpenidClientPkceCodeChallengeMethod            = []string{"", "plain", "S256"}
+	keycloakOpenidClientAccessTypes                          = []string{"CONFIDENTIAL", "PUBLIC", "BEARER-ONLY"}
+	keycloakOpenidClientAuthorizationPolicyEnforcementMode   = []string{"ENFORCING", "PERMISSIVE", "DISABLED"}
+	keycloakOpenidClientResourcePermissionDecisionStrategies = []string{"UNANIMOUS", "AFFIRMATIVE", "CONSENSUS"}
+	keycloakOpenidClientPkceCodeChallengeMethod              = []string{"", "plain", "S256"}
 )
 
 func resourceKeycloakOpenidClient() *schema.Resource {
@@ -117,6 +118,22 @@ func resourceKeycloakOpenidClient() *schema.Resource {
 				Type:     schema.TypeString,
 				Optional: true,
 			},
+			"client_offline_session_idle_timeout": {
+				Type:     schema.TypeString,
+				Optional: true,
+			},
+			"client_offline_session_max_lifespan": {
+				Type:     schema.TypeString,
+				Optional: true,
+			},
+			"client_session_idle_timeout": {
+				Type:     schema.TypeString,
+				Optional: true,
+			},
+			"client_session_max_lifespan": {
+				Type:     schema.TypeString,
+				Optional: true,
+			},
 			"exclude_session_state_from_auth_response": {
 				Type:     schema.TypeBool,
 				Optional: true,
@@ -137,6 +154,12 @@ func resourceKeycloakOpenidClient() *schema.Resource {
 							Required:     true,
 							ValidateFunc: validation.StringInSlice(keycloakOpenidClientAuthorizationPolicyEnforcementMode, false),
 						},
+						"decision_strategy": {
+							Type:         schema.TypeString,
+							Optional:     true,
+							ValidateFunc: validation.StringInSlice(keycloakOpenidClientResourcePermissionDecisionStrategies, false),
+							Default:      "UNANIMOUS",
+						},
 						"allow_remote_resource_management": {
 							Type:     schema.TypeBool,
 							Optional: true,
@@ -238,6 +261,10 @@ func getOpenidClientFromData(data *schema.ResourceData) (*keycloak.OpenidClient,
 			ExcludeSessionStateFromAuthResponse: keycloak.KeycloakBoolQuoted(data.Get("exclude_session_state_from_auth_response").(bool)),
 			AccessTokenLifespan:                 data.Get("access_token_lifespan").(string),
 			LoginTheme:                          data.Get("login_theme").(string),
+			ClientOfflineSessionIdleTimeout:     data.Get("client_offline_session_idle_timeout").(string),
+			ClientOfflineSessionMaxLifespan:     data.Get("client_offline_session_max_lifespan").(string),
+			ClientSessionIdleTimeout:            data.Get("client_session_idle_timeout").(string),
+			ClientSessionMaxLifespan:            data.Get("client_session_max_lifespan").(string),
 		},
 		ValidRedirectUris: validRedirectUris,
 		WebOrigins:        webOrigins,
@@ -275,6 +302,7 @@ func getOpenidClientFromData(data *schema.ResourceData) (*keycloak.OpenidClient,
 		authorizationSettings := authorizationSettingsData.(map[string]interface{})
 		openidClient.AuthorizationSettings = &keycloak.OpenidClientAuthorizationSettings{
 			PolicyEnforcementMode:         authorizationSettings["policy_enforcement_mode"].(string),
+			DecisionStrategy:              authorizationSettings["decision_strategy"].(string),
 			AllowRemoteResourceManagement: authorizationSettings["allow_remote_resource_management"].(bool),
 			KeepDefaults:                  authorizationSettings["keep_defaults"].(bool),
 		}
diff --git a/provider/resource_keycloak_openid_client_authorization_permission.go b/provider/resource_keycloak_openid_client_authorization_permission.go
index 3c913e3..dda7415 100644
--- a/provider/resource_keycloak_openid_client_authorization_permission.go
+++ b/provider/resource_keycloak_openid_client_authorization_permission.go
@@ -10,8 +10,7 @@ import (
 )
 
 var (
-	keycloakOpenidClientResourcePermissionDecisionStrategies = []string{"UNANIMOUS", "AFFIRMATIVE", "CONSENSUS"}
-	keycloakOpenidClientPermissionTypes                      = []string{"resource", "scope"}
+	keycloakOpenidClientPermissionTypes = []string{"resource", "scope"}
 )
 
 func resourceKeycloakOpenidClientAuthorizationPermission() *schema.Resource {


Test patch:
diff --git a/provider/resource_keycloak_openid_client_test.go b/provider/resource_keycloak_openid_client_test.go
index 82a9566..796ce0f 100644
--- a/provider/resource_keycloak_openid_client_test.go
+++ b/provider/resource_keycloak_openid_client_test.go
@@ -282,6 +282,38 @@ func TestAccKeycloakOpenidClient_AccessToken_basic(t *testing.T) {
 	})
 }
 
+func TestAccKeycloakOpenidClient_ClientTimeouts_basic(t *testing.T) {
+	realmName := "terraform-" + acctest.RandString(10)
+	clientId := "terraform-" + acctest.RandString(10)
+
+	offlineSessionIdleTimeout := "1800"
+	offlineSessionMaxLifespan := "1900"
+	sessionIdleTimeout := "2000"
+	sessionMaxLifespan := "2100"
+
+	resource.Test(t, resource.TestCase{
+		ProviderFactories: testAccProviderFactories,
+		PreCheck:          func() { testAccPreCheck(t) },
+		CheckDestroy:      testAccCheckKeycloakOpenidClientDestroy(),
+		Steps: []resource.TestStep{
+			{
+				Config: testKeycloakOpenidClient_ClientTimeouts(realmName, clientId,
+					offlineSessionIdleTimeout, offlineSessionMaxLifespan, sessionIdleTimeout, sessionMaxLifespan),
+				Check: testAccCheckKeycloakOpenidClientExistsWithCorrectClientTimeouts("keycloak_openid_client.client",
+					offlineSessionIdleTimeout, offlineSessionMaxLifespan, sessionIdleTimeout, sessionMaxLifespan,
+				),
+			},
+			{
+				ResourceName:            "keycloak_openid_client.client",
+				ImportState:             true,
+				ImportStateVerify:       true,
+				ImportStateIdPrefix:     realmName + "/",
+				ImportStateVerifyIgnore: []string{"exclude_session_state_from_auth_response"},
+			},
+		},
+	})
+}
+
 func TestAccKeycloakOpenidClient_secret(t *testing.T) {
 	realmName := "terraform-" + acctest.RandString(10)
 	clientId := "terraform-" + acctest.RandString(10)
@@ -547,6 +579,35 @@ func testAccCheckKeycloakOpenidClientExistsWithCorrectLifespan(resourceName stri
 	}
 }
 
+func testAccCheckKeycloakOpenidClientExistsWithCorrectClientTimeouts(resourceName string,
+	offlineSessionIdleTimeout string, offlineSessionMaxLifespan string,
+	sessionIdleTimeout string, sessionMaxLifespan string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		client, err := getOpenidClientFromState(s, resourceName)
+		if err != nil {
+			return err
+		}
+
+		if client.Attributes.ClientOfflineSessionIdleTimeout != offlineSessionIdleTimeout {
+			return fmt.Errorf("expected openid client to have client offline session idle timeout set to %s, but got %s", offlineSessionIdleTimeout, client.Attributes.ClientOfflineSessionIdleTimeout)
+		}
+
+		if client.Attributes.ClientOfflineSessionMaxLifespan != offlineSessionMaxLifespan {
+			return fmt.Errorf("expected openid client to have client offline session max lifespan set to %s, but got %s", offlineSessionMaxLifespan, client.Attributes.ClientOfflineSessionMaxLifespan)
+		}
+
+		if client.Attributes.ClientSessionIdleTimeout != sessionIdleTimeout {
+			return fmt.Errorf("expected openid client to have client session idle timeout set to %s, but got %s", sessionIdleTimeout, client.Attributes.ClientSessionIdleTimeout)
+		}
+
+		if client.Attributes.ClientSessionMaxLifespan != sessionMaxLifespan {
+			return fmt.Errorf("expected openid client to have client session max lifespan set to %s, but got %s", sessionMaxLifespan, client.Attributes.ClientSessionMaxLifespan)
+		}
+
+		return nil
+	}
+}
+
 func testAccCheckKeycloakOpenidClientFetch(resourceName string, client *keycloak.OpenidClient) resource.TestCheckFunc {
 	return func(s *terraform.State) error {
 		fetchedClient, err := getOpenidClientFromState(s, resourceName)
@@ -820,6 +881,27 @@ resource "keycloak_openid_client" "client" {
 	`, realm, clientId, accessTokenLifespan)
 }
 
+func testKeycloakOpenidClient_ClientTimeouts(realm, clientId,
+	offlineSessionIdleTimeout string, offlineSessionMaxLifespan string,
+	sessionIdleTimeout string, sessionMaxLifespan 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 		  = "CONFIDENTIAL"
+
+	client_offline_session_idle_timeout = "%s"
+	client_offline_session_max_lifespan = "%s"
+	client_session_idle_timeout         = "%s"
+	client_session_max_lifespan         = "%s"
+}
+	`, realm, clientId, offlineSessionIdleTimeout, offlineSessionMaxLifespan, sessionIdleTimeout, sessionMaxLifespan)
+}
+
 func testKeycloakOpenidClient_accessType(realm, clientId, accessType string) string {
 	return fmt.Sprintf(`
 resource "keycloak_realm" "realm" {