Skip to content

Commit

Permalink
add extra_config for openid clients to handle custom attributes (#387)
Browse files Browse the repository at this point in the history
  • Loading branch information
Olivier BOUDET authored and olivierboudet committed Aug 31, 2021
1 parent 3f9ef92 commit 078bac5
Show file tree
Hide file tree
Showing 7 changed files with 230 additions and 13 deletions.
6 changes: 6 additions & 0 deletions docs/resources/openid_client.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ resource "keycloak_openid_client" "openid_client" {
]
login_theme = "keycloak"
extra_config = {
"key1" = "value1"
"key2" = "value2"
}
}
```

Expand Down Expand Up @@ -81,6 +86,7 @@ is set to `true`.
- `backchannel_logout_url` - (Optional) The URL that will cause the client to log itself out when a logout request is sent to this realm. If omitted, no logout request will be sent to the client is this case.
- `backchannel_logout_session_required` - (Optional) When `true`, a sid (session ID) claim will be included in the logout token when the backchannel logout URL is used. Defaults to `true`.
- `backchannel_logout_revoke_offline_sessions` - (Optional) Specifying whether a "revoke_offline_access" event is included in the Logout Token when the Backchannel Logout URL is used. Keycloak will revoke offline sessions when receiving a Logout Token with this event.
- `extra_config` - (Optional) A map of key/value pairs to add extra configuration attributes to this client. This can be used for custom attributes, or to add configuration attributes that is not yet supported by this Terraform provider. Use this attribute at your own risk, as s may conflict with top-level configuration attributes in future provider updates.

## Attributes Reference

Expand Down
4 changes: 4 additions & 0 deletions example/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ resource "keycloak_openid_client" "test_client" {
backchannel_logout_url = "http://localhost:3333/backchannel"
backchannel_logout_session_required = true
backchannel_logout_revoke_offline_sessions = true

extra_config = {
customAttribute = "a test custom value"
}
}

resource "keycloak_openid_client_scope" "test_default_client_scope" {
Expand Down
83 changes: 71 additions & 12 deletions keycloak/openid_client.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package keycloak

import (
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
)

type OpenidClientRole struct {
Expand Down Expand Up @@ -55,18 +59,19 @@ type OpenidClient struct {
}

type OpenidClientAttributes struct {
PkceCodeChallengeMethod string `json:"pkce.code.challenge.method"`
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"`
UseRefreshTokens KeycloakBoolQuoted `json:"use.refresh.tokens"`
BackchannelLogoutUrl string `json:"backchannel.logout.url"`
BackchannelLogoutRevokeOfflineTokens KeycloakBoolQuoted `json:"backchannel.logout.revoke.offline.tokens"`
BackchannelLogoutSessionRequired KeycloakBoolQuoted `json:"backchannel.logout.session.required"`
PkceCodeChallengeMethod string `json:"pkce.code.challenge.method"`
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"`
UseRefreshTokens KeycloakBoolQuoted `json:"use.refresh.tokens"`
BackchannelLogoutUrl string `json:"backchannel.logout.url"`
BackchannelLogoutRevokeOfflineTokens KeycloakBoolQuoted `json:"backchannel.logout.revoke.offline.tokens"`
BackchannelLogoutSessionRequired KeycloakBoolQuoted `json:"backchannel.logout.session.required"`
ExtraConfig map[string]interface{} `json:"-"`
}

type OpenidAuthenticationFlowBindingOverrides struct {
Expand Down Expand Up @@ -347,3 +352,57 @@ func (keycloakClient *KeycloakClient) DetachOpenidClientDefaultScopes(realmId, c
func (keycloakClient *KeycloakClient) DetachOpenidClientOptionalScopes(realmId, clientId string, scopeNames []string) error {
return keycloakClient.detachOpenidClientScopes(realmId, clientId, "optional", scopeNames)
}

func (f *OpenidClientAttributes) UnmarshalJSON(data []byte) error {
f.ExtraConfig = map[string]interface{}{}
err := json.Unmarshal(data, &f.ExtraConfig)
if err != nil {
return err
}
v := reflect.ValueOf(f).Elem()
for i := 0; i < v.NumField(); i++ {
structField := v.Type().Field(i)
jsonKey := strings.Split(structField.Tag.Get("json"), ",")[0]
if jsonKey != "-" {
value, ok := f.ExtraConfig[jsonKey]
if ok {
field := v.FieldByName(structField.Name)
if field.IsValid() && field.CanSet() {
if field.Kind() == reflect.String {
field.SetString(value.(string))
} else if field.Kind() == reflect.Bool {
boolVal, err := strconv.ParseBool(value.(string))
if err == nil {
field.Set(reflect.ValueOf(KeycloakBoolQuoted(boolVal)))
}
}
delete(f.ExtraConfig, jsonKey)
}
}
}
}
return nil
}

func (f *OpenidClientAttributes) MarshalJSON() ([]byte, error) {
out := map[string]interface{}{}

for k, v := range f.ExtraConfig {
out[k] = v
}
v := reflect.ValueOf(f).Elem()
for i := 0; i < v.NumField(); i++ {
jsonKey := strings.Split(v.Type().Field(i).Tag.Get("json"), ",")[0]
if jsonKey != "-" {
field := v.Field(i)
if field.IsValid() && field.CanSet() {
if field.Kind() == reflect.String {
out[jsonKey] = field.String()
} else if field.Kind() == reflect.Bool {
out[jsonKey] = KeycloakBoolQuoted(field.Bool())
}
}
}
}
return json.Marshal(out)
}
5 changes: 5 additions & 0 deletions provider/data_source_keycloak_openid_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ func dataSourceKeycloakOpenidClient() *schema.Resource {
Type: schema.TypeBool,
Computed: true,
},
"extra_config": {
Type: schema.TypeMap,
Optional: true,
Computed: true,
},
},
}
}
Expand Down
51 changes: 50 additions & 1 deletion provider/data_source_keycloak_openid_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ package provider

import (
"fmt"
"testing"

"github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
"testing"
)

func TestAccKeycloakDataSourceOpenidClient_basic(t *testing.T) {
Expand Down Expand Up @@ -76,3 +77,51 @@ data "keycloak_openid_client" "test" {
}
`, testAccRealm.Realm, clientId, clientId)
}

func TestAccKeycloakDataSourceOpenidClient_extraConfig(t *testing.T) {
t.Parallel()
clientId := acctest.RandomWithPrefix("tf-acc-test-extra-config")
dataSourceName := "data.keycloak_openid_client.test-extra-config"
resourceName := "keycloak_openid_client.test-extra-config"

resource.Test(t, resource.TestCase{
PreCheck: func() { testAccPreCheck(t) },
ProviderFactories: testAccProviderFactories,
Steps: []resource.TestStep{
{
Config: testAccKeycloakOpenidClientConfig_extraConfig(clientId),
Check: resource.ComposeAggregateTestCheckFunc(
resource.TestCheckResourceAttrPair(dataSourceName, "key1", resourceName, "value1"),
),
},
},
})
}

func testAccKeycloakOpenidClientConfig_extraConfig(clientId string) string {
return fmt.Sprintf(`
data "keycloak_realm" "realm" {
realm = "%s"
}
resource "keycloak_openid_client" "test-extra-config" {
name = "%s"
client_id = "%s"
realm_id = data.keycloak_realm.realm.id
description = "a test openid client with extra_conf"
access_type = "CONFIDENTIAL"
extra_config = {
"key1" = "value1"
}
}
data "keycloak_openid_client" "test-extra-config" {
realm_id = data.keycloak_realm.realm.id
client_id = keycloak_openid_client.test-extra-config.client_id
depends_on = [
keycloak_openid_client.test-extra-config,
]
}
`, testAccRealm.Realm, clientId, clientId)
}
43 changes: 43 additions & 0 deletions provider/resource_keycloak_openid_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import (
"context"
"errors"
"fmt"
"reflect"
"strings"

"github.com/hashicorp/go-cty/cty"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/customdiff"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
Expand Down Expand Up @@ -224,6 +227,37 @@ func resourceKeycloakOpenidClient() *schema.Resource {
Type: schema.TypeBool,
Optional: true,
},
"extra_config": {
Type: schema.TypeMap,
Optional: true,
// you aren't allowed to specify any keys in extra_config that could be defined as top level attributes
ValidateDiagFunc: func(v interface{}, path cty.Path) diag.Diagnostics {
var diags diag.Diagnostics

extraConfig := v.(map[string]interface{})
value := reflect.ValueOf(&keycloak.OpenidClientAttributes{}).Elem()

for i := 0; i < value.NumField(); i++ {
field := value.Field(i)
jsonKey := strings.Split(value.Type().Field(i).Tag.Get("json"), ",")[0]

if jsonKey != "-" && field.CanSet() {
if _, ok := extraConfig[jsonKey]; ok {
diags = append(diags, diag.Diagnostic{
Severity: diag.Error,
Summary: "Invalid extra_config key",
Detail: fmt.Sprintf(`extra_config key "%s" is not allowed, as it conflicts with a top-level schema attribute`, jsonKey),
AttributePath: append(path, cty.IndexStep{
Key: cty.StringVal(jsonKey),
}),
})
}
}
}

return diags
},
},
},
CustomizeDiff: customdiff.ComputedIf("service_account_user_id", func(ctx context.Context, d *schema.ResourceDiff, meta interface{}) bool {
return d.HasChange("service_accounts_enabled")
Expand Down Expand Up @@ -266,6 +300,13 @@ func getOpenidClientFromData(data *schema.ResourceData) (*keycloak.OpenidClient,
}
}

extraConfig := map[string]interface{}{}
if v, ok := data.GetOk("extra_config"); ok {
for key, value := range v.(map[string]interface{}) {
extraConfig[key] = value
}
}

openidClient := &keycloak.OpenidClient{
Id: data.Id(),
ClientId: data.Get("client_id").(string),
Expand All @@ -292,6 +333,7 @@ func getOpenidClientFromData(data *schema.ResourceData) (*keycloak.OpenidClient,
BackchannelLogoutUrl: data.Get("backchannel_logout_url").(string),
BackchannelLogoutRevokeOfflineTokens: keycloak.KeycloakBoolQuoted(data.Get("backchannel_logout_session_required").(bool)),
BackchannelLogoutSessionRequired: keycloak.KeycloakBoolQuoted(data.Get("backchannel_logout_revoke_offline_sessions").(bool)),
ExtraConfig: extraConfig,
},
ValidRedirectUris: validRedirectUris,
WebOrigins: webOrigins,
Expand Down Expand Up @@ -387,6 +429,7 @@ func setOpenidClientData(keycloakClient *keycloak.KeycloakClient, data *schema.R
data.Set("backchannel_logout_url", client.Attributes.BackchannelLogoutUrl)
data.Set("backchannel_logout_session_required", client.Attributes.BackchannelLogoutRevokeOfflineTokens)
data.Set("backchannel_logout_revoke_offline_sessions", client.Attributes.BackchannelLogoutSessionRequired)
data.Set("extra_config", client.Attributes.ExtraConfig)

if client.AuthorizationServicesEnabled {
data.Set("resource_server_id", client.Id)
Expand Down
51 changes: 51 additions & 0 deletions provider/resource_keycloak_openid_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,23 @@ func TestAccKeycloakOpenidClient_useRefreshTokens(t *testing.T) {
})
}

func TestAccKeycloakOpenidClient_extraConfig(t *testing.T) {
t.Parallel()
clientId := acctest.RandomWithPrefix("tf-acc")

resource.Test(t, resource.TestCase{
ProviderFactories: testAccProviderFactories,
PreCheck: func() { testAccPreCheck(t) },
CheckDestroy: testAccCheckKeycloakOpenidClientDestroy(),
Steps: []resource.TestStep{
{
Config: testKeycloakOpenidClient_extraConfig(clientId, "key1", "value1"),
Check: testAccCheckKeycloakOpenidClientExtraConfig("keycloak_openid_client.client", "key1", "value1"),
},
},
})
}

func testAccCheckKeycloakOpenidClientExistsWithCorrectProtocol(resourceName string) resource.TestCheckFunc {
return func(s *terraform.State) error {
client, err := getOpenidClientFromState(s, resourceName)
Expand Down Expand Up @@ -774,6 +791,21 @@ func testAccCheckKeycloakOpenidClientUseRefreshTokens(resourceName string, useRe
}
}

func testAccCheckKeycloakOpenidClientExtraConfig(resourceName string, key string, value string) resource.TestCheckFunc {
return func(s *terraform.State) error {
client, err := getOpenidClientFromState(s, resourceName)
if err != nil {
return err
}

if client.Attributes.ExtraConfig[key] != value {
return fmt.Errorf("expected openid client to have attribute %v set to %v, but got %v", key, value, client.Attributes.ExtraConfig[key])
}

return nil
}
}

func getOpenidClientFromState(s *terraform.State, resourceName string) (*keycloak.OpenidClient, error) {
rs, ok := s.RootModule().Resources[resourceName]
if !ok {
Expand Down Expand Up @@ -1095,6 +1127,7 @@ resource "keycloak_openid_client" "client" {
client_id = "%s"
realm_id = data.keycloak_realm.realm.id
access_type = "PUBLIC"
}
`, testAccRealm.Realm, clientId)
}
Expand Down Expand Up @@ -1129,3 +1162,21 @@ resource "keycloak_openid_client" "client" {
}
`, testAccRealm.Realm, clientId, useRefreshTokens)
}

func testKeycloakOpenidClient_extraConfig(clientId string, key string, value string) string {

return fmt.Sprintf(`
data "keycloak_realm" "realm" {
realm = "%s"
}
resource "keycloak_openid_client" "client" {
client_id = "%s"
realm_id = data.keycloak_realm.realm.id
access_type = "CONFIDENTIAL"
extra_config = {
"%s" = "%s"
}
}
`, testAccRealm.Realm, clientId, key, value)
}

0 comments on commit 078bac5

Please sign in to comment.