Repository: Nerzal/gocloak
Pull request #234 (2020-12-02 19:00:51)

Add a type field to APIError
When refreshing tokens, we would like to be able to differentiate between an error due to an invalid grant, versus an error due to something else. When we receive an error from an invalid grant, we want to prompt the user to re-authenticate via OAuth.

Currently, we can only do that via string matching, which is undesirable in case the string changes in the future. We'd like to instead be able to check a strongly typed error and pivot our logic from that.

This PR adds a new field to the `APIError` used for describing the type of error being returned by Keycloak. We've only added the field _we_ need, but we'd be happy to add more if desired. We check for the `invalid_grant` string defined by Keycloak [here](https://github.com/keycloak/keycloak/blob/master/core/src/main/java/org/keycloak/OAuthErrorException.java#L50) and returned from multiple locations like [here](https://github.com/keycloak/keycloak/blob/master/services/src/main/java/org/keycloak/protocol/oidc/TokenManager.java#L138).

A huge thank you to this library's maintainers! 😄 


Patch:
diff --git a/client.go b/client.go
index 97f8f2b..2a05229 100644
--- a/client.go
+++ b/client.go
@@ -79,12 +79,14 @@ func checkForError(resp *resty.Response, err error, errMessage string) error {
 		return &APIError{
 			Code:    0,
 			Message: errors.Wrap(err, errMessage).Error(),
+			Type:    ParseAPIErrType(err),
 		}
 	}
 
 	if resp == nil {
 		return &APIError{
 			Message: "empty response",
+			Type:    ParseAPIErrType(err),
 		}
 	}
 
@@ -100,6 +102,7 @@ func checkForError(resp *resty.Response, err error, errMessage string) error {
 		return &APIError{
 			Code:    resp.StatusCode(),
 			Message: msg,
+			Type:    ParseAPIErrType(err),
 		}
 	}
 
@@ -439,7 +442,8 @@ func (client *gocloak) GetRequestingPartyPermissions(ctx context.Context, token,
 	return &res, nil
 }
 
-// RefreshToken refreshes the given token
+// RefreshToken refreshes the given token.
+// May return a *APIError with further details about the issue.
 func (client *gocloak) RefreshToken(ctx context.Context, refreshToken, clientID, clientSecret, realm string) (*JWT, error) {
 	return client.GetToken(ctx, realm, TokenOptions{
 		ClientID:     &clientID,
diff --git a/models.go b/models.go
index 5829e3c..ca4f09f 100644
--- a/models.go
+++ b/models.go
@@ -58,10 +58,39 @@ func (s *StringOrArray) MarshalJSON() ([]byte, error) {
 	return json.Marshal([]string(*s))
 }
 
+// APIErrType is a field containing more specific API error types
+// that may be checked by the receiver.
+type APIErrType string
+
+const (
+	// APIErrTypeUnknown is for API errors that are not strongly
+	// typed.
+	APIErrTypeUnknown APIErrType = "unknown"
+
+	// APIErrTypeInvalidGrant corresponds with Keycloak's
+	// OAuthErrorException due to "invalid_grant".
+	APIErrTypeInvalidGrant = "oauth: invalid grant"
+)
+
+// ParseAPIErrType is a convenience method for returning strongly
+// typed API errors.
+func ParseAPIErrType(err error) APIErrType {
+	if err == nil {
+		return APIErrTypeUnknown
+	}
+	switch {
+	case strings.Contains(err.Error(), "invalid_grant"):
+		return APIErrTypeInvalidGrant
+	default:
+		return APIErrTypeUnknown
+	}
+}
+
 // APIError holds message and statusCode for api errors
 type APIError struct {
-	Code    int    `json:"code"`
-	Message string `json:"message"`
+	Code    int        `json:"code"`
+	Message string     `json:"message"`
+	Type    APIErrType `json:"type"`
 }
 
 // Error stringifies the APIError


Test patch:
diff --git a/client_test.go b/client_test.go
index cdca950..f553fca 100644
--- a/client_test.go
+++ b/client_test.go
@@ -4675,6 +4675,7 @@ func TestGoCloak_CheckError(t *testing.T) {
 	expectedError := &gocloak.APIError{
 		Code:    http.StatusNotFound,
 		Message: "404 Not Found: Could not find client",
+		Type:    gocloak.APIErrTypeUnknown,
 	}
 
 	apiError := err.(*gocloak.APIError)
diff --git a/model_test.go b/model_test.go
index ad33b72..c76f730 100644
--- a/model_test.go
+++ b/model_test.go
@@ -2,6 +2,7 @@ package gocloak_test
 
 import (
 	"encoding/json"
+	"errors"
 	"testing"
 
 	"github.com/Nerzal/gocloak/v7"
@@ -87,3 +88,35 @@ func TestGetQueryParams(t *testing.T) {
 		params,
 	)
 }
+
+func TestParseAPIErrType(t *testing.T) {
+	testCases := []struct {
+		Name     string
+		Error    error
+		Expected gocloak.APIErrType
+	}{
+		{
+			Name:     "nil error",
+			Error:    nil,
+			Expected: gocloak.APIErrTypeUnknown,
+		},
+		{
+			Name:     "invalid grant",
+			Error:    errors.New("something something invalid_grant something"),
+			Expected: gocloak.APIErrTypeInvalidGrant,
+		},
+		{
+			Name:     "other error",
+			Error:    errors.New("something something unsupported_grant_type something"),
+			Expected: gocloak.APIErrTypeUnknown,
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.Name, func(t *testing.T) {
+			result := gocloak.ParseAPIErrType(testCase.Error)
+			if result != testCase.Expected {
+				t.Fatalf("expected %s but received %s", testCase.Expected, result)
+			}
+		})
+	}
+}