diff --git a/auth.go b/auth.go index d934723..640dc47 100644 --- a/auth.go +++ b/auth.go @@ -1,25 +1,18 @@ package sfdc import ( - "crypto/x509" - "encoding/pem" "fmt" "net/url" "time" "github.com/go-resty/resty/v2" - "github.com/golang-jwt/jwt/v5" - "github.com/stellaraf/go-sfdc/internal/util" "github.com/stellaraf/go-utils/encryption" ) -const GRANT_TYPE_JWT_BEARER string = "urn:ietf:params:oauth:grant-type:jwt-bearer" - type Auth struct { InstanceURL *url.URL - privateKey string + clientSecret string clientID string - username string httpClient *resty.Client authURL *url.URL encryption bool @@ -28,65 +21,57 @@ type Auth struct { setAccessTokenCallback SetTokenCallback } -func parsePrivateKey(key []byte) (parsed any, err error) { - parsed, err = x509.ParsePKCS8PrivateKey(key) +func (auth *Auth) IntrospectToken(token string) (*TokenIntrospection, error) { + data := map[string]string{ + "client_id": auth.clientID, + "client_secret": auth.clientSecret, + "token_type_hint": "access_token", + "token": token, + } + req := auth.httpClient.R(). + SetFormData(data). + SetResult(&TokenIntrospection{}). + SetError(&AuthErrorResponse{}) + res, err := req.Post("/services/oauth2/introspect") if err != nil { - parsed, err = x509.ParsePKCS1PrivateKey(key) - if err != nil { - parsed, err = x509.ParseECPrivateKey(key) - if err != nil { - return - } - } + return nil, err } - if parsed == nil { - err = fmt.Errorf("failed to parse private key") - return + if res.IsError() { + err = getSFDCError(res.Error()) + return nil, err } - return -} -func (auth *Auth) GetNewToken() (token *Token, err error) { - expiresAt := time.Now() - expiresAt = expiresAt.Add(time.Second * 300) - // SFDC requires that the audience be a single string, not an array. - jwt.MarshalSingleStringAsArray = false - claims := &jwt.RegisteredClaims{ - Issuer: auth.clientID, - Subject: auth.username, - Audience: jwt.ClaimStrings{auth.authURL.String()}, - ExpiresAt: jwt.NewNumericDate(expiresAt), - } - initialToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - block, _ := pem.Decode([]byte(auth.privateKey)) - if block == nil { - err = fmt.Errorf("failed to decode private key") - return - } - rsaKey, err := parsePrivateKey(block.Bytes) - if err != nil { - return - } - assertion, err := initialToken.SignedString(rsaKey) - if err != nil { - return + intro, ok := res.Result().(*TokenIntrospection) + if !ok { + detail := string(res.Body()) + m := "failed to introspect access token" + if detail != "" { + m += fmt.Sprintf(" due to error: %s", detail) + } + err = fmt.Errorf(m) + return nil, err } + return intro, nil +} +func (auth *Auth) GetNewToken() (*Token, error) { req := auth.httpClient.R(). - SetHeader("content-type", "application/x-www-form-urlencoded"). - SetQueryParam("grant_type", GRANT_TYPE_JWT_BEARER). - SetQueryParam("assertion", assertion). + SetQueryParam("grant_type", "client_credentials"). + SetQueryParam("client_id", auth.clientID). + SetQueryParam("client_secret", auth.clientSecret). SetResult(&Token{}). SetError(&AuthErrorResponse{}) res, err := req.Post("/services/oauth2/token") if err != nil { - return + return nil, err } + if res.IsError() { err = getSFDCError(res.Error()) - return + return nil, err } + token, ok := res.Result().(*Token) if !ok { detail := string(res.Body()) @@ -95,10 +80,17 @@ func (auth *Auth) GetNewToken() (token *Token, err error) { m += fmt.Sprintf(" due to error: %s", detail) } err = fmt.Errorf(m) - return + return nil, err } - token.ExpiresAt = expiresAt - return + + intro, err := auth.IntrospectToken(token.AccessToken) + if err != nil { + return nil, err + } + + token.SetExpiry(intro.Exp) + + return token, nil } func (auth *Auth) GetAccessToken() (token string, err error) { @@ -128,7 +120,7 @@ func (auth *Auth) GetAccessToken() (token string, err error) { } func (auth *Auth) SetAccessToken(token *Token) (err error) { - exp := time.Until(token.ExpiresAt) + exp := time.Until(token.expiresAt) if auth.encryption { var encrypted string encrypted, err = encryption.Encrypt(token.AccessToken, auth.encryptionPassphrase) @@ -151,7 +143,7 @@ func (auth *Auth) CacheNewToken(token *Token) (err error) { } func NewAuth( - clientID, privateKey, username, authURL string, + clientID, clientSecret, authURL string, encryption *string, getAccessTokenCallback CachedTokenCallback, setAccessTokenCallback SetTokenCallback, @@ -172,13 +164,11 @@ func NewAuth( } httpClient.SetHeader("user-agent", "go-sfdc") httpClient.SetBaseURL(fmt.Sprintf("%s://%s", parsedAuthURL.Scheme, parsedAuthURL.Host)) - key := util.FormatPrivateKey(privateKey) auth = &Auth{ InstanceURL: nil, authURL: parsedAuthURL, - username: username, clientID: clientID, - privateKey: key, + clientSecret: clientSecret, encryption: doEncrypt, encryptionPassphrase: passphrase, getAccessTokenCallback: getAccessTokenCallback, diff --git a/auth_test.go b/auth_test.go index 3a8e3c1..1c42954 100644 --- a/auth_test.go +++ b/auth_test.go @@ -48,8 +48,7 @@ func initAuth() (auth *sfdc.Auth, err error) { } auth, err = sfdc.NewAuth( env.ClientID, - env.PrivateKey, - env.AuthUsername, + env.ClientSecret, env.AuthURL, encryptionPassphrase, getAccessToken, @@ -74,8 +73,7 @@ func Test_Auth(t *testing.T) { require.NoError(t, err) _, err = sfdc.NewAuth( "invalid-client-key", - env.PrivateKey, - env.AuthUsername, + env.ClientSecret, env.AuthURL, nil, getAccessToken, diff --git a/client.go b/client.go index 9712b46..0d96978 100644 --- a/client.go +++ b/client.go @@ -1,46 +1,74 @@ package sfdc import ( + "time" + + "github.com/cenkalti/backoff/v4" "github.com/go-resty/resty/v2" ) +const DefaultRetryDuration time.Duration = time.Second * 10 + // Salesforce Client type Client struct { httpClient *resty.Client auth *Auth + timeout time.Duration + backoff backoff.BackOff } -func (client *Client) prepare() (err error) { +func (client *Client) prepare() error { token, err := client.auth.GetAccessToken() if err != nil { - return + return err } client.httpClient.SetAuthToken(token) - return + return nil +} + +// do executes a given resty request method such as Get/Post. If a timeout/backoff is specified, +// the request will be executed and retried within that timeout period. +func (client *Client) Do(doer func(u string) (*resty.Response, error), url string) (*resty.Response, error) { + op := func() (*resty.Response, error) { + return doer(url) + } + if client.timeout == 0 { + return op() + } + return backoff.RetryWithData(op, client.backoff) +} + +// WithRetry specifies a time period in which to retry all requests if a errors are returned. +func (client *Client) WithRetry(timeout time.Duration) *Client { + client.timeout = timeout + client.backoff = backoff.NewExponentialBackOff(backoff.WithMaxElapsedTime(timeout)) + return client } // Create a go-sfdc client and performs initial authentication. func New( - clientID, privateKey, username, authURL string, + clientID, clientSecret, authURL string, encryption *string, - getAccessTokenCallback CachedTokenCallback, - setAccessTokenCallback SetTokenCallback, -) (client *Client, err error) { + getToken CachedTokenCallback, + setToken SetTokenCallback, +) (*Client, error) { auth, err := NewAuth( - clientID, privateKey, username, authURL, + clientID, clientSecret, authURL, encryption, - getAccessTokenCallback, - setAccessTokenCallback, + getToken, + setToken, ) if err != nil { - return + return nil, err } httpClient := resty.New() httpClient.SetBaseURL(auth.InstanceURL.String()) - client = &Client{ + client := &Client{ httpClient: httpClient, auth: auth, + timeout: DefaultRetryDuration, + backoff: backoff.NewExponentialBackOff(backoff.WithMaxElapsedTime(DefaultRetryDuration)), } - return + return client, nil } diff --git a/client_methods.go b/client_methods.go index c96c637..8839349 100644 --- a/client_methods.go +++ b/client_methods.go @@ -28,7 +28,7 @@ func (client *Client) PostToCase(caseID string, content string, feedOptions *Fee feedOptions.Body = content feedOptions.Type = "TextPost" req := client.httpClient.R().SetBody(feedOptions).SetResult(&RecordCreatedResponse{}) - res, err := req.Post(path) + res, err := client.Do(req.Post, path) if err != nil { return } diff --git a/client_methods_test.go b/client_methods_test.go index dbba091..67aec2a 100644 --- a/client_methods_test.go +++ b/client_methods_test.go @@ -19,7 +19,9 @@ func Test_PostToCase(t *testing.T) { Status: "New", Subject: subject, } - newCase, _ := Client.CreateCase(caseData) + newCase, err := Client.CreateCase(caseData) + require.NoError(t, err) + require.NotNil(t, newCase) t.Run("post plain text update", func(t *testing.T) { t.Parallel() postResult, err := Client.PostToCase(newCase.ID, "go-sfdc test plain text comment", nil) diff --git a/client_object_methods.go b/client_object_methods.go index b3f39ee..bb7b7f9 100644 --- a/client_object_methods.go +++ b/client_object_methods.go @@ -19,7 +19,7 @@ func (client *Client) Account(id string) (account *Account, err error) { } path := fmt.Sprintf("%s/%s", basePath, id) req := client.httpClient.R() - res, err := req.Get(path) + res, err := client.Do(req.Get, path) if err != nil { return } @@ -44,7 +44,7 @@ func (client *Client) User(id string) (user *User, err error) { } path := fmt.Sprintf("%s/%s", basePath, id) req := client.httpClient.R() - res, err := req.Get(path) + res, err := client.Do(req.Get, path) if err != nil { return } @@ -69,7 +69,7 @@ func (client *Client) Group(id string) (group *Group, err error) { } path := fmt.Sprintf("%s/%s", basePath, id) req := client.httpClient.R() - res, err := req.Get(path) + res, err := client.Do(req.Get, path) if err != nil { return } @@ -94,7 +94,7 @@ func (client *Client) Case(id string) (_case *Case, err error) { } path := fmt.Sprintf("%s/%s", basePath, id) req := client.httpClient.R() - res, err := req.Get(path) + res, err := client.Do(req.Get, path) if err != nil { return } @@ -119,7 +119,7 @@ func (client *Client) ServiceContract(id string) (contract *ServiceContract, err } path := fmt.Sprintf("%s/%s", basePath, id) req := client.httpClient.R() - res, err := req.Get(path) + res, err := client.Do(req.Get, path) if err != nil { return } @@ -144,7 +144,7 @@ func (client *Client) Contact(id string) (contact *Contact, err error) { } path := fmt.Sprintf("%s/%s", basePath, id) req := client.httpClient.R() - res, err := req.Get(path) + res, err := client.Do(req.Get, path) if err != nil { return } @@ -173,7 +173,7 @@ func (client *Client) UpdateAccount(id string, data any, customFields ...map[str } path := fmt.Sprintf("%s/%s", basePath, id) req := client.httpClient.R().SetBody(body) - res, err := req.Patch(path) + res, err := client.Do(req.Patch, path) if err != nil { return } @@ -207,7 +207,7 @@ func (client *Client) UpdateCase(id string, data *CaseUpdate, customFields ...ma if data.SkipAutoAssign { req.SetHeader("Sforce-Auto-Assign", "FALSE") } - res, err := req.Patch(path) + res, err := client.Do(req.Patch, path) if err != nil { return } @@ -233,7 +233,7 @@ func (client *Client) CreateCase(data *CaseCreate, customFields ...map[string]an SetBody(body). SetResult(&RecordCreatedResponse{}). SetError(SalesforceErrorResponse{}) - res, err := req.Post(basePath) + res, err := client.Do(req.Post, basePath) if err != nil { return } @@ -255,7 +255,7 @@ func (client *Client) FeedItem(id string) (result *FeedItem, err error) { return } req := client.httpClient.R().SetResult(&FeedItem{}) - res, err := req.Get(fmt.Sprintf("%s/%s", basePath, id)) + res, err := client.Do(req.Get, fmt.Sprintf("%s/%s", basePath, id)) if err != nil { return } @@ -277,7 +277,7 @@ func (client *Client) CreateFeedItem(data *FeedItemOptions) (*RecordCreatedRespo return nil, err } req := client.httpClient.R().SetResult(&RecordCreatedResponse{}).SetError(SalesforceErrorResponse{}).SetBody(data) - res, err := req.Post(basePath) + res, err := client.Do(req.Post, basePath) if err != nil { return nil, err } diff --git a/client_soql_methods.go b/client_soql_methods.go index 1f192ea..3ba47aa 100644 --- a/client_soql_methods.go +++ b/client_soql_methods.go @@ -28,7 +28,7 @@ func (soqlClient *SOQLClient[T]) Query(soqlQuery *soql) (results RecordResponse[ SetResult(RecordResponse[T]{}). SetError(SalesforceErrorResponse{}) - res, err := req.Get(path) + res, err := soqlClient.Do(req.Get, path) if err != nil { return } diff --git a/client_soql_test.go b/client_soql_test.go index 4a93f8a..81d21c6 100644 --- a/client_soql_test.go +++ b/client_soql_test.go @@ -10,7 +10,7 @@ import ( ) func Test_SOQL(t *testing.T) { - + t.Parallel() t.Run("where equals", func(t *testing.T) { t.Parallel() expected := "SELECT Id FROM Case WHERE IsClosed = false LIMIT 10" @@ -42,7 +42,7 @@ func Test_SOQL(t *testing.T) { }) t.Run("where starts with query", func(t *testing.T) { t.Parallel() - q := sfdc.SOQL().Select("Id").From("Case").Where("Subject", sfdc.STARTS_WITH, "A").Limit(1) + q := sfdc.SOQL().Select("Id").From("Case").Where("Subject", sfdc.STARTS_WITH, "go").Limit(1) sc := sfdc.NewSOQL[sfdc.OpenCase](Client) results, err := sc.Query(q) require.NoError(t, err) @@ -50,7 +50,7 @@ func Test_SOQL(t *testing.T) { }) t.Run("where ends with query", func(t *testing.T) { t.Parallel() - q := sfdc.SOQL().Select("Id").From("Case").Where("Subject", sfdc.ENDS_WITH, "e").Limit(1) + q := sfdc.SOQL().Select("Id").From("Case").Where("Subject", sfdc.ENDS_WITH, "0").Limit(1) sc := sfdc.NewSOQL[sfdc.OpenCase](Client) results, err := sc.Query(q) require.NoError(t, err) diff --git a/client_test.go b/client_test.go index d7ffb9d..de04156 100644 --- a/client_test.go +++ b/client_test.go @@ -2,12 +2,16 @@ package sfdc_test import ( "fmt" + "regexp" "testing" "time" + "github.com/go-resty/resty/v2" "github.com/muesli/cache2go" "github.com/stellaraf/go-sfdc" "github.com/stellaraf/go-sfdc/internal/env" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) var Client *sfdc.Client @@ -46,7 +50,7 @@ func initClient() (client *sfdc.Client, e env.Environment, err error) { encryptionPassphrase = &e.EncryptionPassphrase } client, err = sfdc.New( - e.ClientID, e.PrivateKey, e.AuthUsername, e.AuthURL, + e.ClientID, e.ClientSecret, e.AuthURL, encryptionPassphrase, getAccessToken, setAccessToken, ) @@ -66,3 +70,60 @@ func createCaseSubject(t *testing.T) string { now := time.Now() return fmt.Sprintf("go-sfdc %s at %s", t.Name(), now.Format(time.RFC3339Nano)) } + +func mockSuccessFn(_ string) (*resty.Response, error) { + cache := cache2go.Cache("go-sfdc-test-client-backoff-1") + iter, err := cache.Value("iter") + if err != nil { + return nil, err + } + c, ok := iter.Data().(int) + if !ok { + return nil, fmt.Errorf("failed to retrieve current iteration from 'go-sfdc-test-client-backoff-1' cache") + } + if c == 3 { + return &resty.Response{}, nil + } + cache.Add("iter", time.Hour, c+1) + return nil, fmt.Errorf("failure %d", c) +} + +func mockFailureFn(_ string) (*resty.Response, error) { + cache := cache2go.Cache("go-sfdc-test-client-backoff-2") + iter, err := cache.Value("iter") + if err != nil { + return nil, err + } + c, ok := iter.Data().(int) + if !ok { + return nil, fmt.Errorf("failed to retrieve current iteration from 'go-sfdc-test-client-backoff-2' cache") + } + cache.Add("iter", time.Hour, c+1) + return nil, fmt.Errorf("failure %d", c) +} + +func Test_Client(t *testing.T) { + cache1 := cache2go.Cache("go-sfdc-test-client-backoff-1") + cache1.Add("iter", time.Hour, 0) + cache2 := cache2go.Cache("go-sfdc-test-client-backoff-2") + cache2.Add("iter", time.Hour, 0) + t.Run("backoff success", func(t *testing.T) { + t.Parallel() + Client.WithRetry(time.Second * 5) + res, err := Client.Do(mockSuccessFn, "") + require.NoError(t, err) + assert.IsType(t, &resty.Response{}, res) + }) + t.Run("backoff failure", func(t *testing.T) { + t.Parallel() + Client.WithRetry(time.Second * 5) + res, err := Client.Do(mockFailureFn, "") + require.Error(t, err) + require.Nil(t, res) + assert.Regexp(t, regexp.MustCompile("failure [3-9]"), err.Error()) + }) + t.Cleanup(func() { + cache1.Flush() + cache2.Flush() + }) +} diff --git a/example_client_test.go b/example_client_test.go index f09efed..7cfa1a5 100644 --- a/example_client_test.go +++ b/example_client_test.go @@ -31,8 +31,8 @@ func ExampleNew() { // Salesforce Connected App OAuth2 Client ID. clientID := "abcdef1234567890" - // Provide the private key used for the Salesforce Connected App. - privateKey := "" + // Salesforce Connected App OAuth2 Client Secret. + clientSecret := "0987654321fedcba" // If set, the encryption passphrase is used to encrypt all values written to the cache // using AES-256-GCM encryption. @@ -47,14 +47,9 @@ func ExampleNew() { // See: https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_jwt_flow.htm authURL := "https://login.salesforce.com" - // Username with which go-sfdc will authenticate to the Salesforce API. - // See: https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_jwt_flow.htm - username := "user@example.com" - client, err := sfdc.New( clientID, - privateKey, - username, + clientSecret, authURL, encryptionPassphrase, getAccessToken, diff --git a/go.mod b/go.mod index 851f6bb..4055b5c 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,8 @@ module github.com/stellaraf/go-sfdc go 1.20 require ( + github.com/cenkalti/backoff/v4 v4.3.0 github.com/go-resty/resty/v2 v2.7.0 - github.com/golang-jwt/jwt/v5 v5.0.0 github.com/joho/godotenv v1.5.1 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 github.com/perimeterx/marshmallow v1.1.5 diff --git a/go.sum b/go.sum index 5aefb6a..059647b 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,10 @@ +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= -github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= -github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= diff --git a/internal/env/env.go b/internal/env/env.go index 8bc3ed8..a227477 100644 --- a/internal/env/env.go +++ b/internal/env/env.go @@ -76,29 +76,29 @@ func loadDotEnv() (err error) { return } -func LoadEnv() (env Environment, err error) { - err = loadDotEnv() +func LoadEnv() (Environment, error) { + err := loadDotEnv() if err != nil { - return + return Environment{}, err } clientId := os.Getenv("SFDC_CLIENT_ID") - privateKey := os.Getenv("SFDC_PRIVATE_KEY") + clientSecret := os.Getenv("SFDC_CLIENT_SECRET") encryptionPassphrase := os.Getenv("SFDC_ENCRYPTION_PASSPHRASE") authURL := os.Getenv("SFDC_AUTH_URL") - authUsername := os.Getenv("SFDC_AUTH_USERNAME") testDataRaw := os.Getenv("SFDC_TEST_DATA") + var testData TestData err = json.Unmarshal([]byte(testDataRaw), &testData) if err != nil { - return + return Environment{}, err } - env = Environment{ + + env := Environment{ ClientID: clientId, + ClientSecret: clientSecret, EncryptionPassphrase: encryptionPassphrase, - PrivateKey: privateKey, AuthURL: authURL, - AuthUsername: authUsername, TestData: testData, } - return + return env, nil } diff --git a/internal/env/types.go b/internal/env/types.go index dd5744c..ee2c6be 100644 --- a/internal/env/types.go +++ b/internal/env/types.go @@ -15,9 +15,8 @@ type TestData struct { type Environment struct { ClientID string `json:"clientId"` - PrivateKey string `json:"privateKey"` + ClientSecret string `json:"clientSecret"` AuthURL string `json:"authUrl"` - AuthUsername string `json:"authUsername"` EncryptionPassphrase string `json:"encryptionPassphrase"` TestData TestData `json:"testData"` } diff --git a/types_auth.go b/types_auth.go index d5e9163..b4e411a 100644 --- a/types_auth.go +++ b/types_auth.go @@ -10,12 +10,35 @@ type CachedTokenCallback func() (string, error) type SetTokenCallback func(token string, expiresIn time.Duration) error type Token struct { - ID string `json:"id"` - Scope string `json:"scope"` - TokenType string `json:"token_type"` - AccessToken string `json:"access_token"` - InstanceURL string `json:"instance_url"` - ExpiresAt time.Time `json:"expiresAt"` + ID string `json:"id"` + Scope string `json:"scope"` + TokenType string `json:"token_type"` + AccessToken string `json:"access_token"` + InstanceURL string `json:"instance_url"` + IssuedAt string `json:"issued_at"` + Signature string `json:"signature"` + expiresAt time.Time +} + +func (token *Token) SetExpiry(exp int) *Token { + token.expiresAt = time.Unix(0, int64(exp)*int64(time.Millisecond)) + return token +} + +func (token *Token) IsExpired() bool { + return time.Now().After(token.expiresAt) +} + +type TokenIntrospection struct { + Active bool `json:"active"` + Scope string `json:"scope"` + ClientID string `json:"client_id"` + Username string `json:"username"` + Sub string `json:"sub"` + TokenType string `json:"token_type"` + Exp int `json:"exp"` + Iat int `json:"iat"` + Nbf int `json:"nbf"` } type JWTClaim struct {