Repository: Nerzal/gocloak
Pull request #484 (2024-08-19 18:39:46)

feat: get child groups and get unregistered required actions
After I upgraded Keycloak from version **19** to **25.0.1**, I found the issue when try to call `GetGroup` and `GetGroupByPath` that the response does not include the sub groups.
- I add the function to call to get child groups by groupID, and also fix the tests to be compatible with Keycloak version 25.0.1 as well.
- I already updated the error messages in test cases because of some error message has changed.
- And it I need to fix `TestGocloak_CreateAndGetRequiredAction` because it cannot call to register required action. So I added `GetUnregisteredRequiredActions` for this test refers to this [test suit](https://github.com/keycloak/keycloak/blob/main/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java#L93).


Patch:
diff --git a/Dockerfile b/Dockerfile
index 1590f82..6ae473d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM quay.io/keycloak/keycloak:19.0
+FROM quay.io/keycloak/keycloak:25.0.1
 COPY testdata data/import
 WORKDIR /opt/keycloak
 ENV KC_HOSTNAME=localhost
@@ -6,6 +6,7 @@ ENV KEYCLOAK_USER=admin
 ENV KEYCLOAK_PASSWORD=secret
 ENV KEYCLOAK_ADMIN=admin
 ENV KEYCLOAK_ADMIN_PASSWORD=secret
-ENV KC_FEATURES=account-api,account2,authorization,client-policies,impersonation,docker,scripts,upload_scripts,admin-fine-grained-authz
+ENV KC_HEALTH_ENABLED=true
+ENV KC_FEATURES=account-api,account3,authorization,client-policies,impersonation,docker,scripts,admin-fine-grained-authz
 RUN /opt/keycloak/bin/kc.sh import --file /data/import/gocloak-realm.json
 ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
diff --git a/README.md b/README.md
index 6eb6b68..0138cc7 100644
--- a/README.md
+++ b/README.md
@@ -118,6 +118,7 @@ To get the `clientId` from `id`, use `GetClients` method with `GetClientsParams{
 
 [GoCloakIface](gocloak_iface.go) holds all methods a client should fulfil.
 
+
 ## Configure gocloak to skip TLS Insecure Verification
 
 ```go
diff --git a/client.go b/client.go
index 96090c7..b6d47ef 100644
--- a/client.go
+++ b/client.go
@@ -1719,6 +1719,28 @@ func (g *GoCloak) GetGroup(ctx context.Context, token, realm, groupID string) (*
 	return &result, nil
 }
 
+// GetChildGroups get child groups of group with id in realm
+func (g *GoCloak) GetChildGroups(ctx context.Context, token, realm, groupID string, params GetChildGroupsParams) ([]*Group, error) {
+	const errMessage = "could not get child groups"
+
+	var result []*Group
+	queryParams, err := GetQueryParams(params)
+	if err != nil {
+		return nil, errors.Wrap(err, errMessage)
+	}
+
+	resp, err := g.GetRequestWithBearerAuth(ctx, token).
+		SetResult(&result).
+		SetQueryParams(queryParams).
+		Get(g.getAdminRealmURL(realm, "groups", groupID, "children"))
+
+	if err := checkForError(resp, err, errMessage); err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
 // GetGroupByPath get group with path in realm
 func (g *GoCloak) GetGroupByPath(ctx context.Context, token, realm, groupPath string) (*Group, error) {
 	const errMessage = "could not get group"
@@ -3740,6 +3762,21 @@ func (g *GoCloak) GetPermissionResources(ctx context.Context, token, realm, idOf
 	return result, nil
 }
 
+// GetScopePermissions returns permissions associated with the client scope
+func (g *GoCloak) GetScopePermissions(ctx context.Context, token, realm, idOfClient, idOfScope string) ([]*PolicyRepresentation, error) {
+	const errMessage = "could not get scope permissions"
+
+	var result []*PolicyRepresentation
+	resp, err := g.GetRequestWithBearerAuth(ctx, token).
+		SetResult(&result).
+		Get(g.getAdminRealmURL(realm, "clients", idOfClient, "authz", "resource-server", "scope", idOfScope, "permissions"))
+	if err := checkForError(resp, err, errMessage); err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
 // GetPermissionScopes returns a client's scopes configured for the given permission id
 func (g *GoCloak) GetPermissionScopes(ctx context.Context, token, realm, idOfClient, permissionID string) ([]*PermissionScope, error) {
 	const errMessage = "could not get permission scopes"
@@ -4179,6 +4216,23 @@ func (g *GoCloak) RegisterRequiredAction(ctx context.Context, token string, real
 	return err
 }
 
+// GetUnregisteredRequiredActions gets a list of unregistered required actions for a given realm
+func (g *GoCloak) GetUnregisteredRequiredActions(ctx context.Context, token string, realm string) ([]*UnregisteredRequiredActionProviderRepresentation, error) {
+	const errMessage = "could not get unregistered required actions"
+
+	var result []*UnregisteredRequiredActionProviderRepresentation
+
+	resp, err := g.GetRequestWithBearerAuth(ctx, token).
+		SetResult(&result).
+		Get(g.getAdminRealmURL(realm, "authentication", "unregistered-required-actions"))
+
+	if err := checkForError(resp, err, errMessage); err != nil {
+		return nil, err
+	}
+
+	return result, nil
+}
+
 // GetRequiredActions gets a list of required actions for a given realm
 func (g *GoCloak) GetRequiredActions(ctx context.Context, token string, realm string) ([]*RequiredActionProviderRepresentation, error) {
 	const errMessage = "could not get required actions"
diff --git a/docker-compose.yml b/docker-compose.yml
index 01bdf32..c467aad 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,3 +1,4 @@
+version: "3"
 services:
   keycloak:
     build: .
@@ -10,12 +11,12 @@ services:
       KC_HEALTH_ENABLED: "true"
     ports:
       - "8080:8080"
+      - "9000:9000"
     healthcheck:
-      test: curl --fail --silent http://localhost:8080/health/ready 2>&1 || exit 1
+      test: curl --fail --silent http://localhost:9000/health/ready 2>&1 || exit 1
       interval: 10s
       timeout: 10s
       retries: 5
     volumes:
-      -  ./testdata/gocloak-realm.json:/opt/keycloak/data/import/gocloak-realm.json
-    entrypoint: ["/opt/keycloak/bin/kc.sh", "start-dev --features=preview --import-realm"]
- 
\ No newline at end of file
+      - ./testdata/gocloak-realm.json:/opt/keycloak/data/import/gocloak-realm.json
+    entrypoint: ["/opt/keycloak/bin/kc.sh", "start-dev", "--features=preview", "--import-realm"]
diff --git a/models.go b/models.go
index a093b23..ccab7ad 100644
--- a/models.go
+++ b/models.go
@@ -193,6 +193,7 @@ type IntroSpectTokenResult struct {
 	AuthTime    *int                  `json:"auth_time,omitempty"`
 	Jti         *string               `json:"jti,omitempty"`
 	Type        *string               `json:"typ,omitempty"`
+	Azp         *string               `json:"azp,omitempty"`
 }
 
 // User represents the Keycloak User Structure
@@ -351,6 +352,15 @@ type GetGroupsParams struct {
 	Search              *string `json:"search,omitempty"`
 }
 
+// GetChildGroupsParams represents the optional parameters for getting child groups
+type GetChildGroupsParams struct {
+	BriefRepresentation *bool   `json:"briefRepresentation,string,omitempty"`
+	Exact               *bool   `json:"exact,string,omitempty"`
+	First               *int    `json:"first,string,omitempty"`
+	Max                 *int    `json:"max,string,omitempty"`
+	Search              *string `json:"search,omitempty"`
+}
+
 // MarshalJSON is a custom json marshaling function to automatically set the Full and BriefRepresentation properties
 // for backward compatibility
 func (obj GetGroupsParams) MarshalJSON() ([]byte, error) {
@@ -463,6 +473,7 @@ type Client struct {
 	BearerOnly                         *bool                           `json:"bearerOnly,omitempty"`
 	ClientAuthenticatorType            *string                         `json:"clientAuthenticatorType,omitempty"`
 	ClientID                           *string                         `json:"clientId,omitempty"`
+	ClientTemplate                     *string                         `json:"clientTemplate,omitempty"`
 	ConsentRequired                    *bool                           `json:"consentRequired,omitempty"`
 	DefaultClientScopes                *[]string                       `json:"defaultClientScopes,omitempty"`
 	DefaultRoles                       *[]string                       `json:"defaultRoles,omitempty"`
@@ -489,6 +500,9 @@ type Client struct {
 	ServiceAccountsEnabled             *bool                           `json:"serviceAccountsEnabled,omitempty"`
 	StandardFlowEnabled                *bool                           `json:"standardFlowEnabled,omitempty"`
 	SurrogateAuthRequired              *bool                           `json:"surrogateAuthRequired,omitempty"`
+	UseTemplateConfig                  *bool                           `json:"useTemplateConfig,omitempty"`
+	UseTemplateMappers                 *bool                           `json:"useTemplateMappers,omitempty"`
+	UseTemplateScope                   *bool                           `json:"useTemplateScope,omitempty"`
 	WebOrigins                         *[]string                       `json:"webOrigins,omitempty"`
 }
 
@@ -1411,6 +1425,11 @@ type RequiredActionProviderRepresentation struct {
 	ProviderID    *string            `json:"providerId,omitempty"`
 }
 
+type UnregisteredRequiredActionProviderRepresentation struct {
+	Name       *string `json:"name,omitempty"`
+	ProviderID *string `json:"providerId,omitempty"`
+}
+
 // ManagementPermissionRepresentation is a representation of management permissions
 // v18: https://www.keycloak.org/docs-api/18.0/rest-api/#_managementpermissionreference
 type ManagementPermissionRepresentation struct {
diff --git a/pkg/jwx/jwx.go b/pkg/jwx/jwx.go
index 8eeefad..8bbae25 100644
--- a/pkg/jwx/jwx.go
+++ b/pkg/jwx/jwx.go
@@ -109,7 +109,7 @@ func decodeRSAPublicKey(e, n *string) (*rsa.PublicKey, error) {
 		return nil, errors.Wrap(err, errMessage)
 	}
 
-	pKey := rsa.PublicKey{N: nInt, E: int(eInt)}
+	pKey := rsa.PublicKey{N: nInt, E: int(eInt)} //nolint
 	return &pKey, nil
 }
 


Test patch:
diff --git a/client_test.go b/client_test.go
index 05f7871..cf50285 100644
--- a/client_test.go
+++ b/client_test.go
@@ -820,7 +820,7 @@ func Test_LoginClient_UnknownRealm(t *testing.T) {
 		cfg.GoCloak.ClientSecret,
 		"ThisRealmDoesNotExist")
 	require.Error(t, err, "Login shouldn't be successful")
-	require.EqualError(t, err, "404 Not Found: Realm does not exist")
+	require.EqualError(t, err, "404 Not Found: Realm does not exist: For more on this error consult the server log at the debug level.")
 }
 
 func Test_GetIssuer(t *testing.T) {
@@ -1410,8 +1410,9 @@ func CreateClientScope(t *testing.T, client gocloak.GoCloakIface, scope *gocloak
 
 	if scope == nil {
 		scope = &gocloak.ClientScope{
-			ID:   GetRandomNameP("client-scope-id-"),
-			Name: GetRandomNameP("client-scope-name-"),
+			ID:       GetRandomNameP("client-scope-id-"),
+			Name:     GetRandomNameP("client-scope-name-"),
+			Protocol: gocloak.StringP("openid-connect"),
 		}
 	}
 
@@ -1422,10 +1423,10 @@ func CreateClientScope(t *testing.T, client gocloak.GoCloakIface, scope *gocloak
 		cfg.GoCloak.Realm,
 		*scope,
 	)
+	require.NoError(t, err, "CreateClientScope failed")
 	if !gocloak.NilOrEmpty(scope.ID) {
 		require.Equal(t, clientScopeID, *scope.ID)
 	}
-	require.NoError(t, err, "CreateClientScope failed")
 	tearDown := func() {
 		err := client.DeleteClientScope(
 			context.Background(),
@@ -2427,6 +2428,40 @@ func Test_GetGroupFull(t *testing.T) {
 	require.True(t, ok, "UserAttributeContains")
 }
 
+func Test_GetChildGroups(t *testing.T) {
+	t.Parallel()
+	cfg := GetConfig(t)
+	client := NewClientWithDebug(t)
+	token := GetAdminToken(t, client)
+
+	tearDown, groupID := CreateGroup(t, client)
+	defer tearDown()
+
+	childGroupIDs := []string{}
+	for i := 0; i < 3; i++ {
+		childGroupID, err := client.CreateChildGroup(context.Background(),
+			token.AccessToken,
+			cfg.GoCloak.Realm,
+			groupID,
+			gocloak.Group{
+				Name: GetRandomNameP("Group"),
+			},
+		)
+		require.NoError(t, err, "CreateChildGroup failed")
+		childGroupIDs = append(childGroupIDs, childGroupID)
+	}
+
+	childGroups, err := client.GetChildGroups(
+		context.Background(),
+		token.AccessToken,
+		cfg.GoCloak.Realm,
+		groupID,
+		gocloak.GetChildGroupsParams{},
+	)
+	require.NoError(t, err, "GetChildGroups failed")
+	require.Len(t, childGroups, len(childGroupIDs))
+}
+
 func Test_GetGroupMembers(t *testing.T) {
 	t.Parallel()
 	cfg := GetConfig(t)
@@ -2604,10 +2639,10 @@ func Test_SendVerifyEmail(t *testing.T) {
 		cfg.GoCloak.Realm,
 		params)
 	if err != nil {
-		if err.Error() == "500 Internal Server Error: Failed to send execute actions email" {
+		if err.Error() == "500 Internal Server Error: Failed to send verify email" {
 			return
 		}
-		require.NoError(t, err, "ExecuteActionsEmail failed")
+		require.NoError(t, err, "SendVerifyEmail failed")
 	}
 }
 
@@ -4830,7 +4865,7 @@ func Test_CreateDeleteClientScopeWithMappers(t *testing.T) {
 		cfg.GoCloak.Realm,
 		id,
 	)
-	require.EqualError(t, err, "404 Not Found: Could not find client scope")
+	require.EqualError(t, err, "404 Not Found: Could not find client scope: For more on this error consult the server log at the debug level.")
 	require.Nil(t, clientScopeActual, "client scope has not been deleted")
 }
 
@@ -5749,8 +5784,8 @@ func Test_GetAuthorizationPolicyScopes(t *testing.T) {
 	require.Equal(t, *scopes[0].ID, scopeID)
 
 	defer func() {
-		scope()
 		policy()
+		scope()
 	}()
 }
 
@@ -6421,7 +6456,7 @@ func Test_CheckError(t *testing.T) {
 
 	expectedError := &gocloak.APIError{
 		Code:    http.StatusNotFound,
-		Message: "404 Not Found: Could not find client",
+		Message: "404 Not Found: Could not find client: For more on this error consult the server log at the debug level.",
 		Type:    gocloak.APIErrTypeUnknown,
 	}
 
@@ -6606,13 +6641,14 @@ func Test_ImportIdentityProviderConfig(t *testing.T) {
 	require.NoError(t, err, "ImportIdentityProviderConfig failed")
 
 	expected := map[string]string{
-		"userInfoUrl":       "https://openidconnect.googleapis.com/v1/userinfo",
-		"validateSignature": "true",
-		"tokenUrl":          "https://oauth2.googleapis.com/token",
-		"authorizationUrl":  "https://accounts.google.com/o/oauth2/v2/auth",
-		"jwksUrl":           "https://www.googleapis.com/oauth2/v3/certs",
-		"issuer":            "https://accounts.google.com",
-		"useJwksUrl":        "true",
+		"userInfoUrl":           "https://openidconnect.googleapis.com/v1/userinfo",
+		"validateSignature":     "true",
+		"tokenUrl":              "https://oauth2.googleapis.com/token",
+		"authorizationUrl":      "https://accounts.google.com/o/oauth2/v2/auth",
+		"jwksUrl":               "https://www.googleapis.com/oauth2/v3/certs",
+		"issuer":                "https://accounts.google.com",
+		"useJwksUrl":            "true",
+		"metadataDescriptorUrl": "https://accounts.google.com/.well-known/openid-configuration",
 	}
 
 	require.Len(
@@ -6687,6 +6723,7 @@ E8go1LcvbfHNyknHu2sptnRq55fHZSHr18vVsQRfDYMG</ds:X509Certificate>
 		"loginHint":                       "false",
 		"enabledFromMetadata":             "true",
 		"idpEntityId":                     "https://accounts.google.com/o/saml2?idpid=C01unc9st",
+		"syncMode":                        "LEGACY",
 	}
 
 	require.Len(
@@ -6871,16 +6908,22 @@ func TestGocloak_CreateAndGetRequiredAction(t *testing.T) {
 	cfg := GetConfig(t)
 	client := NewClientWithDebug(t)
 	token := GetAdminToken(t, client)
+
+	// need to get unregistered required actions first
+	// refer to test suit of Keycloak for more details
+	// https://github.com/keycloak/keycloak/blob/main/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/authentication/RequiredActionsTest.java#L93
+	unregisteredRequiredActions, err := client.GetUnregisteredRequiredActions(context.Background(), token.AccessToken, cfg.GoCloak.Realm)
+	require.NoError(t, err, "Failed to get required actions")
+	require.NotEmpty(t, unregisteredRequiredActions, "Required actions must not be empty")
+	require.NotNil(t, unregisteredRequiredActions[0].Name, "Required action name must not be nil")
+	require.NotNil(t, unregisteredRequiredActions[0].ProviderID, "Required action alias must not be nil")
+
 	requiredAction := gocloak.RequiredActionProviderRepresentation{
-		Alias:         gocloak.StringP("VERIFY_EMAIL_NEW"),
-		Config:        nil,
-		DefaultAction: gocloak.BoolP(false),
-		Enabled:       gocloak.BoolP(true),
-		Name:          gocloak.StringP("Verify Email new"),
-		Priority:      gocloak.Int32P(50),
-		ProviderID:    gocloak.StringP("VERIFY_EMAIL_NEW"),
+		Alias:      unregisteredRequiredActions[0].ProviderID,
+		Name:       unregisteredRequiredActions[0].Name,
+		ProviderID: unregisteredRequiredActions[0].ProviderID,
 	}
-	err := client.RegisterRequiredAction(context.Background(), token.AccessToken, cfg.GoCloak.Realm, requiredAction)
+	err = client.RegisterRequiredAction(context.Background(), token.AccessToken, cfg.GoCloak.Realm, requiredAction)
 	require.NoError(t, err, "Failed to register required action")
 
 	ra, err := client.GetRequiredAction(context.Background(), token.AccessToken, cfg.GoCloak.Realm, *requiredAction.Alias)
diff --git a/run-tests.sh b/run-tests.sh
index f762b3e..2ae0632 100755
--- a/run-tests.sh
+++ b/run-tests.sh
@@ -3,8 +3,8 @@
 docker compose down
 docker compose up -d
 
-keycloakServer=http://localhost:8080
-url="${keycloakServer}/health"
+keycloakServer=http://localhost
+url="${keycloakServer}:9000/health"
 echo "Checking service availability at $url (CTRL+C to exit)"
 while true; do
     response=$(curl -s -o /dev/null -w "%{http_code}" $url)
@@ -13,7 +13,7 @@ while true; do
     fi
     sleep 1
 done
-echo "Service is now available at ${keycloakServer}"
+echo "Service is now available at ${keycloakServer}:8080"
 
 ARGS=()
 if [ $# -gt 0 ]; then