Repository: Nerzal/gocloak
Pull request #161 (2020-04-01 10:05:05)

#160 added support for user federated identities
Issue https://github.com/Nerzal/gocloak/issues/160


awltr: I don't know why the tests failed. Locally my new test was executed successfully.

Patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index f3814ef..5beacf8 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -2,9 +2,6 @@ name: Tests
 on:
   - push
   - pull_request
-env:
-  LINT_VERSION: "v1.23.6"
-  NANCY_VERSION: "v0.0.41"
 jobs:
   tests:
     runs-on: ubuntu-latest
@@ -12,28 +9,19 @@ jobs:
       - name: Install Go
         uses: actions/setup-go@v1
         with:
-          go-version: 1.13.x
+          go-version: 1.14.x
       - name: Checkout code
-        uses: actions/checkout@v1
-      - name: Cache Tools
-        id: cache-tools
-        uses: actions/cache@v1
-        with:
-          path: ~/tools
-          key: ${{ runner.os }}-tools-${LINT_VERSION}-${NANCY_VERSION}
-      - name: Install Tools
-        if: steps.cache-tools.outputs.cache-hit != 'true'
+        uses: actions/checkout@v2
+      - name: golangci-lint
+        env:
+          GOROOT: /go
         run: |
-          mkdir -p ~/tools
-          curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b ~/tools ${LINT_VERSION}
-          curl -L https://github.com/sonatype-nexus-community/nancy/releases/download/${NANCY_VERSION}/nancy-linux.amd64-${NANCY_VERSION} -o ~/tools/nancy
-          chmod +x ~/tools/nancy
-      - name: Update PATH
-        run: echo "::add-path::~/tools"
-      - name: Lint
-        run: golangci-lint run ./...
-      - name: Nancy
-        run: nancy go.sum
+          docker pull golangci/golangci-lint:latest-alpine
+          docker run --rm -v ${PWD}:/app -w /app golangci/golangci-lint:latest-alpine golangci-lint run
+      - name: nancy
+        uses: sonatype-nexus-community/nancy-github-action@master
+        with:
+          target: go.sum
       - name: Run Keycloak
         run: |
           docker pull quay.io/keycloak/keycloak:latest
@@ -50,10 +38,9 @@ jobs:
           docker ps
           docker logs keycloak
       - name: CodeCov
-        if: github.repository == 'Nerzal/gocloak' && github.event == 'push'
-        run: bash <(curl -s https://codecov.io/bash)
-        env:
-          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+        uses: codecov/codecov-action@v1
+        with:
+          file: ./coverage.txt
 
       # Publish benchmarks for the master branch only
       - name: Store Benchmark Result
diff --git a/client.go b/client.go
index 86a75c9..067711c 100644
--- a/client.go
+++ b/client.go
@@ -80,11 +80,8 @@ func checkForError(resp *resty.Response, err error, errMessage string) error {
 	if resp.IsError() {
 		var msg string
 
-		e := resp.Error().(*HTTPErrorResponse)
-		if e != nil && len(e.ErrorMessage) > 0 {
-			msg = fmt.Sprintf("%s: %s", resp.Status(), e.ErrorMessage)
-		} else if e != nil && len(e.Error) > 0 {
-			msg = fmt.Sprintf("%s: %s", resp.Status(), e.Error)
+		if e, ok := resp.Error().(*HTTPErrorResponse); ok && e.NotEmpty() {
+			msg = fmt.Sprintf("%s: %s", resp.Status(), e)
 		} else {
 			msg = resp.Status()
 		}
@@ -1736,6 +1733,43 @@ func (client *gocloak) DeleteClientRoleComposite(token string, realm string, rol
 	return checkForError(resp, err, errMessage)
 }
 
+// GetUserFederatedIdentities gets all user federated identities
+func (client *gocloak) GetUserFederatedIdentities(token, realm, userID string) ([]*FederatedIdentityRepresentation, error) {
+	const errMessage = "could not get user federeated identities"
+
+	var res []*FederatedIdentityRepresentation
+	resp, err := client.getRequestWithBearerAuth(token).
+		SetResult(&res).
+		Get(client.getAdminRealmURL(realm, "users", userID, "federated-identity"))
+
+	if err := checkForError(resp, err, errMessage); err != nil {
+		return nil, err
+	}
+
+	return res, err
+}
+
+// CreateUserFederatedIdentity creates an user federated identity
+func (client *gocloak) CreateUserFederatedIdentity(token, realm, userID, providerID string, federatedIdentityRep FederatedIdentityRepresentation) error {
+	const errMessage = "could not create user federeated identity"
+
+	resp, err := client.getRequestWithBearerAuth(token).
+		SetBody(federatedIdentityRep).
+		Post(client.getAdminRealmURL(realm, "users", userID, "federated-identity", providerID))
+
+	return checkForError(resp, err, errMessage)
+}
+
+// DeleteUserFederatedIdentity deletes an user federated identity
+func (client *gocloak) DeleteUserFederatedIdentity(token, realm, userID, providerID string) error {
+	const errMessage = "could not delete user federeated identity"
+
+	resp, err := client.getRequestWithBearerAuth(token).
+		Delete(client.getAdminRealmURL(realm, "users", userID, "federated-identity", providerID))
+
+	return checkForError(resp, err, errMessage)
+}
+
 // ------------------
 // Identity Providers
 // ------------------
diff --git a/errors.go b/errors.go
index 83dbbd2..6ee7c1a 100644
--- a/errors.go
+++ b/errors.go
@@ -1,7 +1,38 @@
 package gocloak
 
+import (
+	"strings"
+)
+
 // HTTPErrorResponse is a model of an error response
 type HTTPErrorResponse struct {
-	ErrorMessage string `json:"errorMessage,omitempty"`
-	Error        string `json:"error,omitempty"`
+	Error       string `json:"error,omitempty"`
+	Message     string `json:"errorMessage,omitempty"`
+	Description string `json:"error_description,omitempty"`
+}
+
+// String returns a string representation of an error
+func (e HTTPErrorResponse) String() string {
+	var res strings.Builder
+	if len(e.Error) > 0 {
+		res.WriteString(e.Error)
+	}
+	if len(e.Message) > 0 {
+		if res.Len() > 0 {
+			res.WriteString(": ")
+		}
+		res.WriteString(e.Message)
+	}
+	if len(e.Description) > 0 {
+		if res.Len() > 0 {
+			res.WriteString(": ")
+		}
+		res.WriteString(e.Description)
+	}
+	return res.String()
+}
+
+// HasError validates that error is not emptyp
+func (e HTTPErrorResponse) NotEmpty() bool {
+	return len(e.Error) > 0 || len(e.Message) > 0 || len(e.Description) > 0
 }
diff --git a/gocloak.go b/gocloak.go
index f7c72ed..6a46ecc 100644
--- a/gocloak.go
+++ b/gocloak.go
@@ -249,6 +249,12 @@ type GoCloak interface {
 	GetUserSessions(token, realm, userID string) ([]*UserSessionRepresentation, error)
 	// GetUserOfflineSessionsForClient returns offline sessions associated with the user and client
 	GetUserOfflineSessionsForClient(token, realm, userID, clientID string) ([]*UserSessionRepresentation, error)
+	// GetUserFederatedIdentities gets all user federated identities
+	GetUserFederatedIdentities(token, realm, userID string) ([]*FederatedIdentityRepresentation, error)
+	// CreateUserFederatedIdentity creates an user federated identity
+	CreateUserFederatedIdentity(token, realm, userID, providerID string, federatedIdentityRep FederatedIdentityRepresentation) error
+	// DeleteUserFederatedIdentity deletes an user federated identity
+	DeleteUserFederatedIdentity(token, realm, userID, providerID string) error
 
 	// *** Identity Provider **
 	// CreateIdentityProvider creates an identity provider in a realm
diff --git a/models.go b/models.go
index 4f92df5..e29ed96 100644
--- a/models.go
+++ b/models.go
@@ -778,6 +778,13 @@ type ServerInfoRepesentation struct {
 	MemoryInfo *MemoryInfoRepresentation `json:"memoryInfo"`
 }
 
+// FederatedIdentityRepresentation represents an user federated identity
+type FederatedIdentityRepresentation struct {
+	IdentityProvider *string `json:"identityProvider,omitempty"`
+	UserID           *string `json:"userId,omitempty"`
+	UserName         *string `json:"userName,omitempty"`
+}
+
 // IdentityProviderRepresentation represents an identity provider
 type IdentityProviderRepresentation struct {
 	AddReadTokenRoleOnCreate  *bool             `json:"addReadTokenRoleOnCreate,omitempty"`


Test patch:
diff --git a/client_test.go b/client_test.go
index 426e5e2..999137f 100644
--- a/client_test.go
+++ b/client_test.go
@@ -361,29 +361,25 @@ func (w *RestyLogWriter) write(format string, v ...interface{}) {
 func NewClientWithDebug(t testing.TB) GoCloak {
 	cfg := GetConfig(t)
 	client := NewClient(cfg.HostName)
-	restyClient := client.RestyClient()
-	restyClient.SetDebug(true)
-	restyClient.SetLogger(&RestyLogWriter{
-		t: t,
-	})
-
 	cond := func(resp *resty.Response, err error) bool {
 		if resp != nil && resp.IsError() {
-			e := resp.Error().(*HTTPErrorResponse)
-			if e != nil {
-				var msg string
-				if len(e.ErrorMessage) > 0 {
-					msg = e.ErrorMessage
-				} else if len(e.Error) > 0 {
-					msg = e.Error
-				}
-				return strings.HasPrefix(msg, "Cached clientScope not found") || strings.Contains(msg, "unknown_error")
+			if e, ok := resp.Error().(*HTTPErrorResponse); ok {
+				msg := e.String()
+				return strings.Contains(msg, "Cached clientScope not found") || strings.Contains(msg, "unknown_error")
 			}
 		}
 		return false
 	}
-	restyClient.AddRetryCondition(cond)
-	restyClient.SetRetryCount(10)
+
+	restyClient := client.RestyClient()
+	restyClient.
+		SetDebug(true).
+		SetLogger(&RestyLogWriter{
+			t: t,
+		}).
+		SetRetryCount(10).
+		SetRetryWaitTime(time.Second).
+		AddRetryCondition(cond)
 
 	return client
 }
@@ -2657,6 +2653,58 @@ func TestGocloak_AddDeleteClientRoleComposite(t *testing.T) {
 	assert.NoError(t, err, "DeleteClientRoleComposite failed")
 }
 
+func TestGocloak_CreateGetDeleteUserFederatedIdentity(t *testing.T) {
+	t.Parallel()
+	cfg := GetConfig(t)
+	client := NewClientWithDebug(t)
+	token := GetAdminToken(t, client)
+
+	tearDownUser, userID := CreateUser(t, client)
+	defer tearDownUser()
+
+	idp := "google"
+	idprep := IdentityProviderRepresentation{
+		ProviderID:                &idp,
+		Alias:                     StringP("google"),
+		DisplayName:               StringP("Google"),
+		Enabled:                   BoolP(true),
+		TrustEmail:                BoolP(true),
+		FirstBrokerLoginFlowAlias: StringP("first broker login"),
+		Config: map[string]string{
+			"clientId":     cfg.GoCloak.ClientID,
+			"clientSecret": cfg.GoCloak.ClientSecret,
+			"hostedDomain": "test.io",
+		},
+	}
+	res, err := client.CreateIdentityProvider(token.AccessToken, cfg.GoCloak.Realm, idprep)
+	assert.NoError(t, err)
+	assert.Equal(t, idp, res)
+
+	defer func() {
+		err = client.DeleteIdentityProvider(token.AccessToken, cfg.GoCloak.Realm, "google")
+		assert.NoError(t, err)
+	}()
+
+	firep := FederatedIdentityRepresentation{
+		IdentityProvider: &idp,
+		UserID:           StringP("my-external-userid"),
+		UserName:         StringP("my-external-username"),
+	}
+	err = client.CreateUserFederatedIdentity(token.AccessToken, cfg.GoCloak.Realm, userID, idp, firep)
+	assert.NoError(t, err)
+	assert.Equal(t, idp, res)
+
+	defer func() {
+		err = client.DeleteUserFederatedIdentity(token.AccessToken, cfg.GoCloak.Realm, userID, idp)
+		assert.NoError(t, err)
+	}()
+
+	arr, err := client.GetUserFederatedIdentities(token.AccessToken, cfg.GoCloak.Realm, userID)
+	assert.NoError(t, err)
+	assert.Equal(t, 1, len(arr))
+	assert.Equal(t, "my-external-userid", *arr[0].UserID)
+}
+
 func TestGocloak_CreateDeleteClientScopeWithMappers(t *testing.T) {
 	t.Parallel()
 	cfg := GetConfig(t)
@@ -2759,61 +2807,40 @@ func TestGocloak_CreateProvider(t *testing.T) {
 		assert.Equal(t, "google", provider)
 	})
 
-	t.Run("create azure provider", func(t *testing.T) {
+	t.Run("create github provider", func(t *testing.T) {
 		repr := IdentityProviderRepresentation{
-			Alias:                     StringP("azure-oidc"),
-			DisplayName:               StringP("Azure"),
+			Alias:                     StringP("github"),
+			DisplayName:               StringP("GitHub"),
 			Enabled:                   BoolP(true),
-			ProviderID:                StringP("oidc"),
+			ProviderID:                StringP("github"),
 			TrustEmail:                BoolP(true),
 			FirstBrokerLoginFlowAlias: StringP("first broker login"),
 			Config: map[string]string{
-				"clientId":         cfg.GoCloak.ClientID,
-				"clientSecret":     cfg.GoCloak.ClientSecret,
-				"authorizationUrl": "authorization-url",
-				"tokenUrl":         "token-url",
-				"logoutUrl":        "logout-url",
-				"userInfoUrl":      "userinfo-url",
-				"issuer":           "test-issuer",
-				"jwksUrl":          "jwks-url",
+				"clientId":     cfg.GoCloak.ClientID,
+				"clientSecret": cfg.GoCloak.ClientSecret,
 			},
 		}
 		provider, err := client.CreateIdentityProvider(token.AccessToken, cfg.GoCloak.Realm, repr)
 		assert.NoError(t, err)
-		assert.Equal(t, "azure-oidc", provider)
+		assert.Equal(t, "github", provider)
 	})
 
-	t.Run("create OIDC V1.0 provider", func(t *testing.T) {
+	t.Run("create microsoft provider", func(t *testing.T) {
 		repr := IdentityProviderRepresentation{
-			Alias:                     StringP("oidc"),
-			DisplayName:               StringP("custom-oidc"),
+			Alias:                     StringP("microsoft"),
+			DisplayName:               StringP("Microsoft"),
 			Enabled:                   BoolP(true),
-			ProviderID:                StringP("oidc"),
+			ProviderID:                StringP("microsoft"),
 			TrustEmail:                BoolP(true),
 			FirstBrokerLoginFlowAlias: StringP("first broker login"),
 			Config: map[string]string{
-				"clientId":                 cfg.GoCloak.ClientID,
-				"clientSecret":             cfg.GoCloak.ClientSecret,
-				"authorizationUrl":         "authorization-url",
-				"tokenUrl":                 "token-url",
-				"logoutUrl":                "logout-url",
-				"userInfoUrl":              "userinfo-url",
-				"issuer":                   "test-issuer",
-				"loginHint":                "true",
-				"validateSignature":        "true",
-				"backchannelLogout":        "false",
-				"useJwksUrl":               "true",
-				"uiLocales":                "true",
-				"disableUserInfo":          "true",
-				"defaultScopes":            "default-scope",
-				"prompt":                   "false",
-				"allowedClockSkew":         "10",
-				"forwardedQueryParameters": "forwarded-query-parameters",
+				"clientId":     cfg.GoCloak.ClientID,
+				"clientSecret": cfg.GoCloak.ClientSecret,
 			},
 		}
 		provider, err := client.CreateIdentityProvider(token.AccessToken, cfg.GoCloak.Realm, repr)
 		assert.NoError(t, err)
-		assert.Equal(t, "oidc", provider)
+		assert.Equal(t, "microsoft", provider)
 	})
 
 	t.Run("Update google provider", func(t *testing.T) {
@@ -2850,19 +2877,19 @@ func TestGocloak_CreateProvider(t *testing.T) {
 		assert.Equal(t, 2, len(providers))
 	})
 
-	t.Run("Get Azure provider", func(t *testing.T) {
-		provider, err := client.GetIdentityProvider(token.AccessToken, cfg.GoCloak.Realm, "azure-oidc")
+	t.Run("Get microsoft provider", func(t *testing.T) {
+		provider, err := client.GetIdentityProvider(token.AccessToken, cfg.GoCloak.Realm, "microsoft")
 		assert.NoError(t, err)
-		assert.Equal(t, "azure-oidc", *(provider.Alias))
+		assert.Equal(t, "microsoft", *(provider.Alias))
 	})
 
-	t.Run("Delete Azure provider", func(t *testing.T) {
-		err := client.DeleteIdentityProvider(token.AccessToken, cfg.GoCloak.Realm, "azure-oidc")
+	t.Run("Delete microsoft provider", func(t *testing.T) {
+		err := client.DeleteIdentityProvider(token.AccessToken, cfg.GoCloak.Realm, "microsoft")
 		assert.NoError(t, err)
 	})
 
-	t.Run("Delete OIDC V1.0 provider", func(t *testing.T) {
-		err := client.DeleteIdentityProvider(token.AccessToken, cfg.GoCloak.Realm, "oidc")
+	t.Run("Delete github provider", func(t *testing.T) {
+		err := client.DeleteIdentityProvider(token.AccessToken, cfg.GoCloak.Realm, "github")
 		assert.NoError(t, err)
 	})
 }