diff --git a/.secrets.baseline b/.secrets.baseline index 061cce8e..c4c85808 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "go.sum|package-lock.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2023-09-22T20:47:15Z", + "generated_at": "2023-11-06T17:25:56Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -247,26 +247,26 @@ "verified_result": null }, { - "hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750", + "hashed_secret": "2a68d46242baf9214502d1dc240a9075a7c6ed55", "is_secret": false, "is_verified": false, - "line_number": 71, + "line_number": 79, "type": "Secret Keyword", "verified_result": null }, { - "hashed_secret": "2a68d46242baf9214502d1dc240a9075a7c6ed55", + "hashed_secret": "333f0f8814d63e7268f80e1e65e7549137d2350c", "is_secret": false, "is_verified": false, - "line_number": 79, + "line_number": 88, "type": "Secret Keyword", "verified_result": null }, { - "hashed_secret": "333f0f8814d63e7268f80e1e65e7549137d2350c", + "hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750", "is_secret": false, "is_verified": false, - "line_number": 88, + "line_number": 92, "type": "Secret Keyword", "verified_result": null } @@ -320,7 +320,7 @@ "hashed_secret": "d4c3d66fd0c38547a3c7a4c6bdc29c36911bc030", "is_secret": false, "is_verified": false, - "line_number": 45, + "line_number": 46, "type": "Secret Keyword", "verified_result": null }, @@ -328,7 +328,7 @@ "hashed_secret": "8318df9ecda039deac9868adf1944a29a95c7114", "is_secret": false, "is_verified": false, - "line_number": 48, + "line_number": 49, "type": "Secret Keyword", "verified_result": null } @@ -637,6 +637,66 @@ "verified_result": null } ], + "core/mcsp_authenticator.go": [ + { + "hashed_secret": "347cd9c53ff77d41a7b22aa56c7b4efaf54658e3", + "is_secret": false, + "is_verified": false, + "line_number": 279, + "type": "Secret Keyword", + "verified_result": null + } + ], + "core/mcsp_authenticator_test.go": [ + { + "hashed_secret": "fd08cd887ed1de2f2d3e175117ff607ca65187ae", + "is_secret": false, + "is_verified": false, + "line_number": 35, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "d03d939c22ad66a948ec8b4649add9b12b8a3cf6", + "is_secret": false, + "is_verified": false, + "line_number": 38, + "type": "JSON Web Token", + "verified_result": null + }, + { + "hashed_secret": "5dcb6cb71ea20f1a58387e3d36d77bd123eb9f3b", + "is_secret": false, + "is_verified": false, + "line_number": 39, + "type": "JSON Web Token", + "verified_result": null + }, + { + "hashed_secret": "65e496a8c40e0364f378688b5e612a2386ad38d1", + "is_secret": false, + "is_verified": false, + "line_number": 646, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "4c809455939f19c33c732b56a8417e509f4885e8", + "is_secret": false, + "is_verified": false, + "line_number": 647, + "type": "Secret Keyword", + "verified_result": null + }, + { + "hashed_secret": "32e8612d8ca77c7ea8374aa7918db8e5df9252ed", + "is_secret": false, + "is_verified": false, + "line_number": 669, + "type": "Secret Keyword", + "verified_result": null + } + ], "core/utils_test.go": [ { "hashed_secret": "0266262f439c732a31b9353ced05c9e777a07c54", @@ -741,26 +801,26 @@ "verified_result": null }, { - "hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750", + "hashed_secret": "4e44e97dae1aa4e93c01536f48bbd8602133a86d", "is_secret": false, "is_verified": false, - "line_number": 54, + "line_number": 66, "type": "Secret Keyword", "verified_result": null }, { - "hashed_secret": "4e44e97dae1aa4e93c01536f48bbd8602133a86d", + "hashed_secret": "00cafd126182e8a9e7c01bb2f0dfd00496be724f", "is_secret": false, "is_verified": false, - "line_number": 66, + "line_number": 85, "type": "Secret Keyword", "verified_result": null }, { - "hashed_secret": "00cafd126182e8a9e7c01bb2f0dfd00496be724f", + "hashed_secret": "f2e7745f43b0ef0e2c2faf61d6c6a28be2965750", "is_secret": false, "is_verified": false, - "line_number": 85, + "line_number": 90, "type": "Secret Keyword", "verified_result": null }, @@ -768,7 +828,7 @@ "hashed_secret": "9e2659aa7e2b335ec6bdcf180f3b6f41f5191af5", "is_secret": false, "is_verified": false, - "line_number": 90, + "line_number": 96, "type": "Secret Keyword", "verified_result": null } diff --git a/core/authenticator_factory.go b/core/authenticator_factory.go index b02bc1ef..20d7e340 100644 --- a/core/authenticator_factory.go +++ b/core/authenticator_factory.go @@ -1,6 +1,6 @@ package core -// (C) Copyright IBM Corp. 2019, 2021. +// (C) Copyright IBM Corp. 2019, 2023. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -58,6 +58,8 @@ func GetAuthenticatorFromEnvironment(credentialKey string) (authenticator Authen authenticator, err = newVpcInstanceAuthenticatorFromMap(properties) } else if strings.EqualFold(authType, AUTHTYPE_CP4D) { authenticator, err = newCloudPakForDataAuthenticatorFromMap(properties) + } else if strings.EqualFold(authType, AUTHTYPE_MCSP) { + authenticator, err = newMCSPAuthenticatorFromMap(properties) } else if strings.EqualFold(authType, AUTHTYPE_NOAUTH) { authenticator, err = NewNoAuthAuthenticator() } else { diff --git a/core/authenticator_factory_test.go b/core/authenticator_factory_test.go index 1fd14ed8..3750f808 100644 --- a/core/authenticator_factory_test.go +++ b/core/authenticator_factory_test.go @@ -3,7 +3,7 @@ package core -// (C) Copyright IBM Corp. 2019. +// (C) Copyright IBM Corp. 2019, 2023. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -119,6 +119,18 @@ func TestGetAuthenticatorFromEnvironment1(t *testing.T) { assert.Equal(t, "user1", iamAuth.ClientId) assert.Equal(t, "secret1", iamAuth.ClientSecret) assert.Equal(t, "https://iam.refresh-token.com", iamAuth.URL) + + // MCSP Authenticator. + authenticator, err = GetAuthenticatorFromEnvironment("service10") + assert.Nil(t, err) + assert.NotNil(t, authenticator) + assert.Equal(t, AUTHTYPE_MCSP, authenticator.AuthenticationType()) + mcspAuth, ok := authenticator.(*MCSPAuthenticator) + assert.True(t, ok) + assert.NotNil(t, mcspAuth) + assert.Equal(t, "my-api-key", mcspAuth.ApiKey) + assert.Equal(t, "https://mcsp.ibm.com", mcspAuth.URL) + assert.True(t, mcspAuth.DisableSSLVerification) } func TestGetAuthenticatorFromEnvironment2(t *testing.T) { @@ -207,6 +219,17 @@ func TestGetAuthenticatorFromEnvironment2(t *testing.T) { assert.True(t, ok) assert.NotNil(t, containerAuth) assert.Equal(t, "iam-user2", containerAuth.IAMProfileName) + + authenticator, err = GetAuthenticatorFromEnvironment("service14") + assert.Nil(t, err) + assert.NotNil(t, authenticator) + assert.Equal(t, AUTHTYPE_MCSP, authenticator.AuthenticationType()) + mcspAuth, ok := authenticator.(*MCSPAuthenticator) + assert.True(t, ok) + assert.NotNil(t, mcspAuth) + assert.Equal(t, "my-api-key", mcspAuth.ApiKey) + assert.Equal(t, "https://mcsp.ibm.com", mcspAuth.URL) + assert.True(t, mcspAuth.DisableSSLVerification) } func TestGetAuthenticatorFromEnvironment3(t *testing.T) { diff --git a/core/common_test.go b/core/common_test.go index 18729aba..2eb333ee 100644 --- a/core/common_test.go +++ b/core/common_test.go @@ -87,6 +87,10 @@ var testEnvironment = map[string]string{ "SERVICE11_AUTH_TYPE": "bad_auth_type", "SERVICE12_APIKEY": "my-apikey", "SERVICE13_IAM_PROFILE_NAME": "iam-user2", + "SERVICE14_AUTH_TYPE": "mcsp", + "SERVICE14_AUTH_URL": "https://mcsp.ibm.com", + "SERVICE14_APIKEY": "my-api-key", + "SERVICE14_AUTH_DISABLE_SSL": "true", } // setTestEnvironment sets the environment variables described in our map. diff --git a/core/constants.go b/core/constants.go index ca4ba8a1..d48d1802 100644 --- a/core/constants.go +++ b/core/constants.go @@ -1,6 +1,6 @@ package core -// (C) Copyright IBM Corp. 2019, 2022. +// (C) Copyright IBM Corp. 2019, 2023. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ const ( AUTHTYPE_CP4D = "cp4d" AUTHTYPE_CONTAINER = "container" AUTHTYPE_VPC = "vpc" + AUTHTYPE_MCSP = "mcsp" // Names of properties that can be defined as part of an external configuration (credential file, env vars, etc.). // Example: export MYSERVICE_URL=https://myurl diff --git a/core/mcsp_authenticator.go b/core/mcsp_authenticator.go new file mode 100644 index 00000000..7f3975aa --- /dev/null +++ b/core/mcsp_authenticator.go @@ -0,0 +1,415 @@ +package core + +// (C) Copyright IBM Corp. 2023. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "net/http/httputil" + "strconv" + "sync" + "time" +) + +// MCSPAuthenticator uses an apikey to obtain an access token, +// and adds the access token to requests via an Authorization header +// of the form: "Authorization: Bearer "" +type MCSPAuthenticator struct { + + // [Required] The apikey used to fetch the bearer token from the token server. + ApiKey string + + // [Required] The endpoint base URL for the token server. + URL string + + // [Optional] A flag that indicates whether verification of the token server's SSL certificate + // should be disabled; defaults to false. + DisableSSLVerification bool + + // [Optional] A set of key/value pairs that will be sent as HTTP headers in requests + // made to the token server. + Headers map[string]string + + // [Optional] The http.Client object used to invoke token server requests. + // If not specified by the user, a suitable default Client will be constructed. + Client *http.Client + clientInit sync.Once + + // The cached token and expiration time. + tokenData *mcspTokenData + + // Mutex to make the tokenData field thread safe. + tokenDataMutex sync.Mutex +} + +var mcspRequestTokenMutex sync.Mutex +var mcspNeedsRefreshMutex sync.Mutex + +const ( + mcspAuthOperationPath = "/siusermgr/api/1.0/apikeys/token" +) + +// MCSPAuthenticatorBuilder is used to construct an MCSPAuthenticator instance. +type MCSPAuthenticatorBuilder struct { + MCSPAuthenticator +} + +// NewMCSPAuthenticatorBuilder returns a new builder struct that +// can be used to construct an MCSPAuthenticator instance. +func NewMCSPAuthenticatorBuilder() *MCSPAuthenticatorBuilder { + return &MCSPAuthenticatorBuilder{} +} + +// SetApiKey sets the ApiKey field in the builder. +func (builder *MCSPAuthenticatorBuilder) SetApiKey(s string) *MCSPAuthenticatorBuilder { + builder.MCSPAuthenticator.ApiKey = s + return builder +} + +// SetURL sets the URL field in the builder. +func (builder *MCSPAuthenticatorBuilder) SetURL(s string) *MCSPAuthenticatorBuilder { + builder.MCSPAuthenticator.URL = s + return builder +} + +// SetDisableSSLVerification sets the DisableSSLVerification field in the builder. +func (builder *MCSPAuthenticatorBuilder) SetDisableSSLVerification(b bool) *MCSPAuthenticatorBuilder { + builder.MCSPAuthenticator.DisableSSLVerification = b + return builder +} + +// SetHeaders sets the Headers field in the builder. +func (builder *MCSPAuthenticatorBuilder) SetHeaders(headers map[string]string) *MCSPAuthenticatorBuilder { + builder.MCSPAuthenticator.Headers = headers + return builder +} + +// SetClient sets the Client field in the builder. +func (builder *MCSPAuthenticatorBuilder) SetClient(client *http.Client) *MCSPAuthenticatorBuilder { + builder.MCSPAuthenticator.Client = client + return builder +} + +// Build() returns a validated instance of the MCSPAuthenticator with the config that was set in the builder. +func (builder *MCSPAuthenticatorBuilder) Build() (*MCSPAuthenticator, error) { + + // Make sure the config is valid. + err := builder.MCSPAuthenticator.Validate() + if err != nil { + return nil, err + } + + return &builder.MCSPAuthenticator, nil +} + +// client returns the authenticator's http client after potentially initializing it. +func (authenticator *MCSPAuthenticator) client() *http.Client { + authenticator.clientInit.Do(func() { + if authenticator.Client == nil { + authenticator.Client = DefaultHTTPClient() + authenticator.Client.Timeout = time.Second * 30 + + // If the user told us to disable SSL verification, then do it now. + if authenticator.DisableSSLVerification { + transport := &http.Transport{ + // #nosec G402 + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + authenticator.Client.Transport = transport + } + } + }) + return authenticator.Client +} + +// newMCSPAuthenticatorFromMap constructs a new MCSPAuthenticator instance from a map. +func newMCSPAuthenticatorFromMap(properties map[string]string) (authenticator *MCSPAuthenticator, err error) { + if properties == nil { + return nil, fmt.Errorf(ERRORMSG_PROPS_MAP_NIL) + } + + disableSSL, err := strconv.ParseBool(properties[PROPNAME_AUTH_DISABLE_SSL]) + if err != nil { + disableSSL = false + } + + authenticator, err = NewMCSPAuthenticatorBuilder(). + SetApiKey(properties[PROPNAME_APIKEY]). + SetURL(properties[PROPNAME_AUTH_URL]). + SetDisableSSLVerification(disableSSL). + Build() + + return +} + +// AuthenticationType returns the authentication type for this authenticator. +func (*MCSPAuthenticator) AuthenticationType() string { + return AUTHTYPE_MCSP +} + +// Authenticate adds the Authorization header to the request. +// The value will be of the form: "Authorization: Bearer "" +func (authenticator *MCSPAuthenticator) Authenticate(request *http.Request) error { + token, err := authenticator.GetToken() + if err != nil { + return err + } + + request.Header.Set("Authorization", "Bearer "+token) + return nil +} + +// getTokenData returns the tokenData field from the authenticator. +func (authenticator *MCSPAuthenticator) getTokenData() *mcspTokenData { + authenticator.tokenDataMutex.Lock() + defer authenticator.tokenDataMutex.Unlock() + + return authenticator.tokenData +} + +// setTokenData sets the given mcspTokenData to the tokenData field of the authenticator. +func (authenticator *MCSPAuthenticator) setTokenData(tokenData *mcspTokenData) { + authenticator.tokenDataMutex.Lock() + defer authenticator.tokenDataMutex.Unlock() + + authenticator.tokenData = tokenData + GetLogger().Info("setTokenData: expiration=%d, refreshTime=%d", + authenticator.tokenData.Expiration, authenticator.tokenData.RefreshTime) +} + +// Validate the authenticator's configuration. +// +// Ensures that the ApiKey and URL properties are both specified. +func (authenticator *MCSPAuthenticator) Validate() error { + + if authenticator.ApiKey == "" { + return fmt.Errorf(ERRORMSG_PROP_MISSING, "ApiKey") + } + + if authenticator.URL == "" { + return fmt.Errorf(ERRORMSG_PROP_MISSING, "URL") + } + + return nil +} + +// GetToken: returns an access token to be used in an Authorization header. +// Whenever a new token is needed (when a token doesn't yet exist, needs to be refreshed, +// or the existing token has expired), a new access token is fetched from the token server. +func (authenticator *MCSPAuthenticator) GetToken() (string, error) { + if authenticator.getTokenData() == nil || !authenticator.getTokenData().isTokenValid() { + // synchronously request the token + err := authenticator.synchronizedRequestToken() + if err != nil { + return "", err + } + } else if authenticator.getTokenData().needsRefresh() { + // If refresh needed, kick off a go routine in the background to get a new token. + //nolint: errcheck + go authenticator.invokeRequestTokenData() + } + + // return an error if the access token is not valid or was not fetched + if authenticator.getTokenData() == nil || authenticator.getTokenData().AccessToken == "" { + return "", fmt.Errorf("Error while trying to get access token") + } + + return authenticator.getTokenData().AccessToken, nil +} + +// synchronizedRequestToken: synchronously checks if the current token in cache +// is valid. If token is not valid or does not exist, it will fetch a new token. +func (authenticator *MCSPAuthenticator) synchronizedRequestToken() error { + mcspRequestTokenMutex.Lock() + defer mcspRequestTokenMutex.Unlock() + // if cached token is still valid, then just continue to use it + if authenticator.getTokenData() != nil && authenticator.getTokenData().isTokenValid() { + return nil + } + + return authenticator.invokeRequestTokenData() +} + +// invokeRequestTokenData: requests a new token from the access server and +// unmarshals the token information to the tokenData cache. Returns +// an error if the token was unable to be fetched, otherwise returns nil +func (authenticator *MCSPAuthenticator) invokeRequestTokenData() error { + tokenResponse, err := authenticator.RequestToken() + if err != nil { + return err + } + + GetLogger().Info("invokeRequestTokenData(): RequestToken returned tokenResponse:\n%+v", *tokenResponse) + tokenData, err := newMCSPTokenData(tokenResponse) + if err != nil { + tokenData = &mcspTokenData{} + } + + authenticator.setTokenData(tokenData) + + return nil +} + +// RequestToken fetches a new access token from the token server. +func (authenticator *MCSPAuthenticator) RequestToken() (*MCSPTokenServerResponse, error) { + + builder := NewRequestBuilder(POST) + _, err := builder.ResolveRequestURL(authenticator.URL, mcspAuthOperationPath, nil) + if err != nil { + return nil, err + } + + builder.AddHeader(CONTENT_TYPE, "application/json") + builder.AddHeader(Accept, APPLICATION_JSON) + requestBody := fmt.Sprintf(`{"apikey":"%s"}`, authenticator.ApiKey) + _, _ = builder.SetBodyContentString(requestBody) + + // Add user-defined headers to request. + for headerName, headerValue := range authenticator.Headers { + builder.AddHeader(headerName, headerValue) + } + + req, err := builder.Build() + if err != nil { + return nil, err + } + + // If debug is enabled, then dump the request. + if GetLogger().IsLogLevelEnabled(LevelDebug) { + buf, dumpErr := httputil.DumpRequestOut(req, req.Body != nil) + if dumpErr == nil { + GetLogger().Debug("Request:\n%s\n", RedactSecrets(string(buf))) + } else { + GetLogger().Debug(fmt.Sprintf("error while attempting to log outbound request: %s", dumpErr.Error())) + } + } + + GetLogger().Debug("Invoking IBM Verify 'get token' operation: %s", builder.URL) + resp, err := authenticator.client().Do(req) + if err != nil { + return nil, err + } + GetLogger().Debug("Returned from IBM Verify 'get token' operation, received status code %d", resp.StatusCode) + + // If debug is enabled, then dump the response. + if GetLogger().IsLogLevelEnabled(LevelDebug) { + buf, dumpErr := httputil.DumpResponse(resp, req.Body != nil) + if dumpErr == nil { + GetLogger().Debug("Response:\n%s\n", RedactSecrets(string(buf))) + } else { + GetLogger().Debug(fmt.Sprintf("error while attempting to log inbound response: %s", dumpErr.Error())) + } + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + buff := new(bytes.Buffer) + _, _ = buff.ReadFrom(resp.Body) + + // Create a DetailedResponse to be included in the error below. + detailedResponse := &DetailedResponse{ + StatusCode: resp.StatusCode, + Headers: resp.Header, + RawResult: buff.Bytes(), + } + + errorMsg := string(detailedResponse.RawResult) + if errorMsg == "" { + errorMsg = + fmt.Sprintf("unexpected status code %d received from IBM Verify token server %s", detailedResponse.StatusCode, builder.URL) + } + return nil, NewAuthenticationError(detailedResponse, fmt.Errorf(errorMsg)) + } + + tokenResponse := &MCSPTokenServerResponse{} + _ = json.NewDecoder(resp.Body).Decode(tokenResponse) + defer resp.Body.Close() // #nosec G307 + + return tokenResponse, nil +} + +// MCSPTokenServerResponse : This struct models a response received from the token server. +type MCSPTokenServerResponse struct { + Token string `json:"token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +// mcspTokenData : This struct represents the cached information related to a fetched access token. +type mcspTokenData struct { + AccessToken string + RefreshTime int64 + Expiration int64 +} + +// newMCSPTokenData: constructs a new mcspTokenData instance from the specified +// MCSPTokenServerResponse instance. +func newMCSPTokenData(tokenResponse *MCSPTokenServerResponse) (*mcspTokenData, error) { + if tokenResponse == nil || tokenResponse.Token == "" { + return nil, fmt.Errorf("Error while trying to parse access token!") + } + + // Need to crack open the access token (a JWT) to get the expiration and issued-at times + // so that we can compute the refresh time. + claims, err := parseJWT(tokenResponse.Token) + if err != nil { + return nil, err + } + + // Compute the adjusted refresh time (expiration time - 20% of timeToLive) + timeToLive := claims.ExpiresAt - claims.IssuedAt + expireTime := claims.ExpiresAt + refreshTime := expireTime - int64(float64(timeToLive)*0.2) + + tokenData := &mcspTokenData{ + AccessToken: tokenResponse.Token, + Expiration: expireTime, + RefreshTime: refreshTime, + } + + GetLogger().Info("newMCSPTokenData: expiration=%d, refreshTime=%d", tokenData.Expiration, tokenData.RefreshTime) + + return tokenData, nil +} + +// isTokenValid: returns true iff the mcspTokenData instance represents a valid (non-expired) access token. +func (tokenData *mcspTokenData) isTokenValid() bool { + if tokenData.AccessToken != "" && GetCurrentTime() < tokenData.Expiration { + GetLogger().Info("isTokenValid: Token is valid!") + return true + } + GetLogger().Info("isTokenValid: Token is NOT valid!") + GetLogger().Info("isTokenValid: expiration=%d, refreshTime=%d", tokenData.Expiration, tokenData.RefreshTime) + GetLogger().Info("GetCurrentTime(): %d\n", GetCurrentTime()) + return false +} + +// needsRefresh: synchronously returns true iff the currently stored access token should be refreshed. This method also +// updates the refresh time if it determines the token needs refreshed to prevent other threads from +// making multiple refresh calls. +func (tokenData *mcspTokenData) needsRefresh() bool { + mcspNeedsRefreshMutex.Lock() + defer mcspNeedsRefreshMutex.Unlock() + + // Advance refresh by one minute + if tokenData.RefreshTime >= 0 && GetCurrentTime() > tokenData.RefreshTime { + tokenData.RefreshTime = GetCurrentTime() + 60 + return true + } + + return false +} diff --git a/core/mcsp_authenticator_test.go b/core/mcsp_authenticator_test.go new file mode 100644 index 00000000..d2dd8f45 --- /dev/null +++ b/core/mcsp_authenticator_test.go @@ -0,0 +1,726 @@ +//go:build all || slow || auth +// +build all slow auth + +package core + +// (C) Copyright IBM Corp. 2023. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + assert "github.com/stretchr/testify/assert" +) + +var ( + // To enable debug logging during test execution, set this to "LevelDebug" + mcspAuthTestLogLevel LogLevel = LevelError + mcspAuthMockApiKey = "mock-apikey" + mcspAuthMockURL = "https://mock.mcsp.com" + + mcspAuthTestAccessToken1 string = "eyJraWQiOiJ0WlZVVnQxSmZYR0ZSM3VuczFQLU12cWJuSGE0c2hPUnRJZEM1ZDR0d2o0IiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL3NpdXNlcm1ndG1zLW1zcC11c2VyLW1hbmFnZXIuYXBwcy5hcC1kcC0xMDEuMnd4aC5wMS5vcGVuc2hpZnRhcHBzLmNvbS9zaXVzZXJtZ3IvYXBpLzEuMCIsImF1ZCI6ImNybjp2MTphd3Mtc3RhZ2luZzpwdWJsaWM6d3hvOnVzLWVhc3QtMTpzdWIvMjAyMzEwMjUtMDQ1Ni01NzI3LTIwNWMtOGU2YWJhM2FiZmUzOjIwMjMxMDI1LTEwMzUtMzY2Ny01MDA2LWUzNjU1N2IyOGRhODo6IiwiZXhwIjoxNjk5MDMzNzM2LCJqdGkiOiJfRFpHSWJPbHlSWmF5TjlFTUowWXpBIiwiaWF0IjoxNjk5MDI2NTM2LCJuYmYiOjE2OTkwMjY1MDYsInRlbmFudElkIjoiMjAyMzEwMjUtMTAzNS0zNjY3LTUwMDYtZTM2NTU3YjI4ZGE4Iiwic3Vic2NyaXB0aW9uSWQiOiIyMDIzMTAyNS0wNDU2LTU3MjctMjA1Yy04ZTZhYmEzYWJmZTMiLCJzdWIiOiI5MGRjZjU4ZC00NzgzLTNmOGUtOGMxNi05ZGU3NTMwNDE0ODAiLCJlbnRpdHlUeXBlIjoiVVNFUiIsImVtYWlsIjoic3Z0X3N0YWdlX2Vzc2VudGlhbEB3by1jZC50ZXN0aW5hdG9yLmNvbSIsIm5hbWUiOiJzdnRfc3RhZ2VfZXNzZW50aWFsQHdvLWNkLnRlc3RpbmF0b3IuY29tIiwiZGlzcGxheW5hbWUiOiJzdnRfc3RhZ2VfZXNzZW50aWFsQHdvLWNkLnRlc3RpbmF0b3IuY29tIiwiaWRwIjp7InJlYWxtTmFtZSI6ImNsb3VkSWRlbnRpdHlSZWFsbSIsImlzcyI6Imh0dHBzOi8vd28taWJtLXN0Zy52ZXJpZnkuaWJtLmNvbS9vaWRjL2VuZHBvaW50L2RlZmF1bHQifSwiZ3JvdXBzIjpbXSwicm9sZXMiOlsiQWRtaW4iXX0.alYTel_rX1JlN9tciTLl5fXSjs4CYbjq7Ywow8aGVG0ONm_GYNyNfhUQ4SGxvvxpA7inXQg-Hcx_K0pTEVPqrV-OUMNBcXJXcAO-ZszEcDgca_BdSxOAVTXV5Y8LkbBRJjJn3bzcZ5Yq0y0cTP0z-tSnRtmP8USyLrOclE3WLV966t_AFi2i0t1FnHFi7pHBoji4idwDK3uYHhduXsHDjiHD2QmydFXKNHYAIAP8De9aCDLsRfVE56ga9Gx2CQ46R5V5tfy5KkYor6RtBAifn-TZUGX5OOai3V-5DqtUrVtIdIGODJCAhFYiruOu4INOgwPdLQgzF0V3uqYeifyQCw" + mcspAuthTestAccessToken2 string = "eyJraWQiOiJ0WlZVVnQxSmZYR0ZSM3VuczFQLU12cWJuSGE0c2hPUnRJZEM1ZDR0d2o0IiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL3NpdXNlcm1ndG1zLW1zcC11c2VyLW1hbmFnZXIuYXBwcy5hcC1kcC0xMDEuMnd4aC5wMS5vcGVuc2hpZnRhcHBzLmNvbS9zaXVzZXJtZ3IvYXBpLzEuMCIsImF1ZCI6ImNybjp2MTphd3Mtc3RhZ2luZzpwdWJsaWM6d3hvOnVzLWVhc3QtMTpzdWIvMjAyMzEwMjUtMDQ1Ni01NzI3LTIwNWMtOGU2YWJhM2FiZmUzOjIwMjMxMDI1LTEwMzUtMzY2Ny01MDA2LWUzNjU1N2IyOGRhODo6IiwiZXhwIjoxNjk5MDQ1MDUyLCJqdGkiOiI1dkpvdk85SXJtRnUwWlZTTFBxTmZnIiwiaWF0IjoxNjk5MDM3ODUyLCJuYmYiOjE2OTkwMzc4MjIsInRlbmFudElkIjoiMjAyMzEwMjUtMTAzNS0zNjY3LTUwMDYtZTM2NTU3YjI4ZGE4Iiwic3Vic2NyaXB0aW9uSWQiOiIyMDIzMTAyNS0wNDU2LTU3MjctMjA1Yy04ZTZhYmEzYWJmZTMiLCJzdWIiOiI5MGRjZjU4ZC00NzgzLTNmOGUtOGMxNi05ZGU3NTMwNDE0ODAiLCJlbnRpdHlUeXBlIjoiVVNFUiIsImVtYWlsIjoic3Z0X3N0YWdlX2Vzc2VudGlhbEB3by1jZC50ZXN0aW5hdG9yLmNvbSIsIm5hbWUiOiJzdnRfc3RhZ2VfZXNzZW50aWFsQHdvLWNkLnRlc3RpbmF0b3IuY29tIiwiZGlzcGxheW5hbWUiOiJzdnRfc3RhZ2VfZXNzZW50aWFsQHdvLWNkLnRlc3RpbmF0b3IuY29tIiwiaWRwIjp7InJlYWxtTmFtZSI6ImNsb3VkSWRlbnRpdHlSZWFsbSIsImlzcyI6Imh0dHBzOi8vd28taWJtLXN0Zy52ZXJpZnkuaWJtLmNvbS9vaWRjL2VuZHBvaW50L2RlZmF1bHQifSwiZ3JvdXBzIjpbXSwicm9sZXMiOlsiQWRtaW4iXX0.eFDY62qebPUehd-Bkz9xNzJjNwoGkLYBFhybo-Py97gc100wp9WItBcC409O86mZxsH79zCDqGOHNrrVirh11yv0iv7D2_wt9hHDpHsG48pNmzvLzkRKy-a7xW_YsYB_Es3h3FeXv-nRWBxWLGdel6kkW-OAl1hnuC53r0n2ADO863ifbUlvzhxECWJSsMMCH_ZSJ_ejzGQcKNtPMRYNAgnsdey5qEvQ_Ae_ntt7iGCsOpYfmky0U3CZhMd9QkIvoQC8ulpkYmusmVQzAosCqQtgNGSBP2ekvYgI79v3ZB3c3oQC1aEJOuUGXhrbP7PRnLAkgnEZDAbrIMlQyP9ddA" +) + +// Tests involving the Builder +func TestMCSPAuthBuilderErrors(t *testing.T) { + var err error + var auth *MCSPAuthenticator + + // Error: no apikey + auth, err = NewMCSPAuthenticatorBuilder(). + SetApiKey(""). + SetURL(mcspAuthMockURL). + Build() + assert.NotNil(t, err) + assert.Nil(t, auth) + t.Logf("Expected error: %s", err.Error()) + + // Error: no url + auth, err = NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(""). + Build() + assert.NotNil(t, err) + assert.Nil(t, auth) + t.Logf("Expected error: %s", err.Error()) +} + +func TestMCSPAuthBuilderSuccess(t *testing.T) { + var err error + var auth *MCSPAuthenticator + var expectedHeaders = map[string]string{ + "header1": "value1", + } + + // Specify apikey. + auth, err = NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(mcspAuthMockURL). + SetClient(nil). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + assert.Equal(t, mcspAuthMockApiKey, auth.ApiKey) + assert.Equal(t, mcspAuthMockURL, auth.URL) + assert.False(t, auth.DisableSSLVerification) + assert.Nil(t, auth.Headers) + assert.Equal(t, AUTHTYPE_MCSP, auth.AuthenticationType()) + + // Specify apikey with other properties. + auth, err = NewMCSPAuthenticatorBuilder(). + SetURL(mcspAuthMockURL). + SetApiKey(mcspAuthMockApiKey). + SetDisableSSLVerification(true). + SetHeaders(expectedHeaders). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + assert.Equal(t, mcspAuthMockApiKey, auth.ApiKey) + assert.Equal(t, mcspAuthMockURL, auth.URL) + assert.True(t, auth.DisableSSLVerification) + assert.Equal(t, expectedHeaders, auth.Headers) + assert.Equal(t, AUTHTYPE_MCSP, auth.AuthenticationType()) +} + +func TestMCSPAuthReuseAuthenticator(t *testing.T) { + auth, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(mcspAuthMockURL). + Build() + assert.Nil(t, err) + assert.NotNil(t, auth) + + // Use the authenticator to construct a service. + service, err := NewBaseService(&ServiceOptions{ + URL: "don't care", + Authenticator: auth, + }) + assert.Nil(t, err) + assert.NotNil(t, service) + + // Now re-use the authenticator with a new service. + service, err = NewBaseService(&ServiceOptions{ + URL: "don't care", + Authenticator: auth, + }) + assert.Nil(t, err) + assert.NotNil(t, service) +} + +// Tests that construct an authenticator via map properties. +func TestNewMCSPAuthenticatorFromMap(t *testing.T) { + _, err := newMCSPAuthenticatorFromMap(nil) + assert.NotNil(t, err) + + // Missing ApiKey + var props = map[string]string{ + PROPNAME_AUTH_URL: mcspAuthMockURL, + } + _, err = newMCSPAuthenticatorFromMap(props) + assert.NotNil(t, err) + + // Missing URL + props = map[string]string{ + PROPNAME_APIKEY: mcspAuthMockApiKey, + } + _, err = newMCSPAuthenticatorFromMap(props) + assert.NotNil(t, err) + + // Valid configuration. + props = map[string]string{ + PROPNAME_APIKEY: mcspAuthMockApiKey, + PROPNAME_AUTH_URL: mcspAuthMockURL, + } + authenticator, err := newMCSPAuthenticatorFromMap(props) + assert.Nil(t, err) + assert.NotNil(t, authenticator) + assert.Equal(t, mcspAuthMockApiKey, authenticator.ApiKey) + assert.Equal(t, mcspAuthMockURL, authenticator.URL) + assert.Equal(t, AUTHTYPE_MCSP, authenticator.AuthenticationType()) + + props = map[string]string{ + PROPNAME_AUTH_URL: mcspAuthMockURL, + PROPNAME_APIKEY: mcspAuthMockApiKey, + PROPNAME_AUTH_DISABLE_SSL: "false", + } + authenticator, err = newMCSPAuthenticatorFromMap(props) + assert.Nil(t, err) + assert.NotNil(t, authenticator) + assert.Equal(t, mcspAuthMockApiKey, authenticator.ApiKey) + assert.Equal(t, mcspAuthMockURL, authenticator.URL) + assert.False(t, authenticator.DisableSSLVerification) + assert.Equal(t, AUTHTYPE_MCSP, authenticator.AuthenticationType()) +} + +func TestMCSPAuthenticateFail(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("Sorry you are not authorized")) + })) + defer server.Close() + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.NotNil(t, authenticator) + + // Create a new Request object. + builder, err := NewRequestBuilder("GET").ConstructHTTPURL("https://localhost/placeholder/url", nil, nil) + assert.Nil(t, err) + + request, err := builder.Build() + assert.Nil(t, err) + assert.NotNil(t, request) + + err = authenticator.Authenticate(request) + assert.NotNil(t, err) + authErr, ok := err.(*AuthenticationError) + assert.True(t, ok) + assert.NotNil(t, authErr) + assert.EqualValues(t, authErr, err) + // The casted error should match the original error message + assert.Equal(t, err.Error(), authErr.Error()) +} + +// Struct that describes the requestBody for the "get token" operation. +type mcspRequestBody struct { + ApiKey *string `json:"apikey,omitempty"` +} + +func startMockServer(t *testing.T) *httptest.Server { + firstCall := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + // Unmarshal the request body and verify. + requestBody := &mcspRequestBody{} + _ = json.NewDecoder(req.Body).Decode(requestBody) + defer req.Body.Close() + assert.NotNil(t, requestBody.ApiKey) + assert.Equal(t, mcspAuthMockApiKey, *requestBody.ApiKey) + + // Create the response. + w.WriteHeader(http.StatusOK) + if firstCall { + fmt.Fprintf(w, `{"token":"%s","token_type":"jwt","expires_in":7200}`, mcspAuthTestAccessToken1) + firstCall = false + } else { + fmt.Fprintf(w, `{"token":"%s","token_type":"jwt","expires_in":7200}`, mcspAuthTestAccessToken2) + } + })) + return server +} + +func TestMCSPGetTokenSuccess(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + server := startMockServer(t) + defer server.Close() + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.NotNil(t, authenticator) + assert.Nil(t, authenticator.getTokenData()) + + // Force the first fetch and verify we got the first access token. + token, err := authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) + + // Force expiration and verify that we got the second access token. + authenticator.getTokenData().Expiration = GetCurrentTime() - 7200 + _, err = authenticator.GetToken() + assert.Nil(t, err) + assert.NotNil(t, authenticator.getTokenData()) + assert.Equal(t, mcspAuthTestAccessToken2, authenticator.getTokenData().AccessToken) +} + +func TestMCSPGetCachedToken(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + server := startMockServer(t) + defer server.Close() + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.Nil(t, authenticator.getTokenData()) + + // Force the first fetch and verify we got the first access token. + token, err := authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) + + // Set the expiration time to "force" the use of the cached token. + tokenData := authenticator.getTokenData() + tokenData.Expiration = GetCurrentTime() + 1800 + tokenData.RefreshTime = GetCurrentTime() + 1500 + + // Subsequent fetch should still return first access token. + token, err = authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) +} + +func TestMCSPBackgroundTokenRefresh(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + server := startMockServer(t) + defer server.Close() + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.Nil(t, authenticator.getTokenData()) + + // Force the first fetch and verify we got the first access token. + token, err := authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) + + // Now put the test in the "refresh window" where the token is not expired but still needs to be refreshed. + tokenData := authenticator.getTokenData() + tokenData.Expiration = GetCurrentTime() + 1800 + tokenData.RefreshTime = GetCurrentTime() - 720 + + // Authenticator should detect the need to refresh and request a new access token IN THE BACKGROUND when we call + // GetToken() again. The immediate response should be the token which was already stored, since it's not yet + // expired. + token, err = authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) + + // Wait for the background thread to finish + time.Sleep(5 * time.Second) + token, err = authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken2, token) + assert.NotNil(t, authenticator.getTokenData()) +} + +func TestMCSPBackgroundTokenRefreshFailure(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + firstCall := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if firstCall { + fmt.Fprintf(w, `{"token":"%s","token_type":"jwt","expires_in": 7200}`, mcspAuthTestAccessToken1) + firstCall = false + } else { + _, _ = w.Write([]byte("Sorry you are forbidden")) + } + })) + defer server.Close() + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.Nil(t, authenticator.getTokenData()) + + // Successfully fetch the first token. + token, err := authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) + + // Now put the test in the "refresh window" where the token is not expired but still needs to be refreshed. + tokenData := authenticator.getTokenData() + tokenData.Expiration = GetCurrentTime() + 1800 + tokenData.RefreshTime = GetCurrentTime() - 720 + + // Authenticator should detect the need to refresh and request a new access token IN THE BACKGROUND when we call + // GetToken() again. The immediate response should be the token which was already stored, since it's not yet + // expired. + token, err = authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) + + // Wait for the background thread to finish. + time.Sleep(5 * time.Second) + _, err = authenticator.GetToken() + assert.NotNil(t, err) + assert.Equal(t, "Error while trying to get access token", err.Error()) + // We don't expect an AuthenticateError to be returned, so casting should fail + _, ok := err.(*AuthenticationError) + assert.False(t, ok) +} + +func TestMCSPBackgroundTokenRefreshIdle(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + server := startMockServer(t) + defer server.Close() + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.Nil(t, authenticator.getTokenData()) + + // Force the first fetch and verify we got the first access token. + token, err := authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) + + // Now simulate the client being idle for 10 minutes into the refresh time + tenMinutesBeforeNow := GetCurrentTime() - 600 + tokenData := authenticator.getTokenData() + tokenData.Expiration = GetCurrentTime() + 1800 + tokenData.RefreshTime = tenMinutesBeforeNow + + // Authenticator should detect the need to refresh and request a new access token IN THE BACKGROUND when we call + // GetToken() again. The immediate response should be the token which was already stored, since it's not yet + // expired. + token, err = authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) + + // RefreshTime should have advanced by 1 minute from the current time + newRefreshTime := GetCurrentTime() + 60 + assert.Equal(t, newRefreshTime, authenticator.getTokenData().RefreshTime) + + // In the next request, the RefreshTime should be unchanged and another thread + // shouldn't be spawned to request another token once more since the first thread already spawned + // a goroutine & refreshed the token. + token, err = authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + + assert.NotNil(t, authenticator.getTokenData()) + assert.Equal(t, newRefreshTime, authenticator.getTokenData().RefreshTime) + + // Wait for the background thread to finish and verify both the RefreshTime & tokenData were updated + time.Sleep(5 * time.Second) + token, err = authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken2, token) + assert.NotNil(t, authenticator.getTokenData()) + assert.NotEqual(t, newRefreshTime, authenticator.getTokenData().RefreshTime) +} + +func TestMCSPDisableSSL(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + server := startMockServer(t) + defer server.Close() + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + SetDisableSSLVerification(true). + Build() + assert.Nil(t, err) + + token, err := authenticator.GetToken() + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.Nil(t, err) + assert.NotNil(t, authenticator.Client) + assert.NotNil(t, authenticator.Client.Transport) + transport, ok := authenticator.Client.Transport.(*http.Transport) + assert.True(t, ok) + assert.NotNil(t, transport.TLSClientConfig) + assert.True(t, transport.TLSClientConfig.InsecureSkipVerify) +} + +func TestMCSPUserHeaders(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"token":"%s","token_type":"jwt","expires_in":7200}`, mcspAuthTestAccessToken1) + assert.Equal(t, "Value1", r.Header.Get("Header1")) + assert.Equal(t, "Value2", r.Header.Get("Header2")) + assert.Equal(t, "mcsp.cloud.ibm.com", r.Host) + })) + defer server.Close() + + var headers = map[string]string{ + "Header1": "Value1", + "Header2": "Value2", + "Host": "mcsp.cloud.ibm.com", + } + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + SetHeaders(headers). + Build() + assert.Nil(t, err) + + token, err := authenticator.GetToken() + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.Nil(t, err) +} + +func TestMCSPGetTokenFailure(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + var expectedResponse = []byte("Sorry you are forbidden") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write(expectedResponse) + })) + defer server.Close() + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + Build() + assert.Nil(t, err) + + _, err = authenticator.GetToken() + assert.NotNil(t, err) + assert.Equal(t, string(expectedResponse), err.Error()) + + // We expect an AuthenticationError to be returned, so cast the returned error. + authError, ok := err.(*AuthenticationError) + assert.True(t, ok) + assert.NotNil(t, authError) + assert.NotNil(t, authError.Error()) + assert.NotNil(t, authError.Response) + rawResult := authError.Response.GetRawResult() + assert.NotNil(t, rawResult) + assert.Equal(t, expectedResponse, rawResult) + statusCode := authError.Response.GetStatusCode() + assert.Equal(t, string(expectedResponse), authError.Error()) + assert.Equal(t, http.StatusForbidden, statusCode) +} + +func TestMCSPGetTokenTimeoutError(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + firstCall := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if firstCall { + fmt.Fprintf(w, `{"token":"%s","token_type":"jwt","expires_in":7200}`, mcspAuthTestAccessToken1) + firstCall = false + } else { + time.Sleep(3 * time.Second) + fmt.Fprintf(w, `{"token":"%s","token_type":"jwt","expires_in":7200}`, mcspAuthTestAccessToken2) + } + })) + defer server.Close() + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.Nil(t, authenticator.getTokenData()) + + // Force the first fetch and verify we got the first access token. + token, err := authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) + + // Force expiration and verify that we got a timeout error + authenticator.getTokenData().Expiration = GetCurrentTime() - 3600 + + // Set the client timeout to something very low + authenticator.Client.Timeout = time.Second * 2 + token, err = authenticator.GetToken() + assert.Empty(t, token) + assert.NotNil(t, err) + assert.NotNil(t, err.Error()) + + // We don't expect a AuthenticateError to be returned, so casting should fail. + _, ok := err.(*AuthenticationError) + assert.False(t, ok) +} + +func TestMCSPGetTokenServerError(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + var expectedResponse = []byte("Gateway Timeout") + + firstCall := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if firstCall { + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, `{"token":"%s","token_type":"jwt","expires_in":7200}`, mcspAuthTestAccessToken1) + firstCall = false + } else { + w.WriteHeader(http.StatusGatewayTimeout) + _, _ = w.Write(expectedResponse) + } + })) + defer server.Close() + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(server.URL). + Build() + assert.Nil(t, err) + assert.Nil(t, authenticator.getTokenData()) + + // Force the first fetch and verify we got the first access token. + token, err := authenticator.GetToken() + assert.Nil(t, err) + assert.Equal(t, mcspAuthTestAccessToken1, token) + assert.NotNil(t, authenticator.getTokenData()) + + // Force expiration and verify that we got a server error + authenticator.getTokenData().Expiration = GetCurrentTime() - 3600 + token, err = authenticator.GetToken() + assert.NotNil(t, err) + + // We expect an AuthenticationError to be returned, so cast the returned error. + authError, ok := err.(*AuthenticationError) + assert.True(t, ok) + assert.NotNil(t, authError) + assert.NotNil(t, authError.Response) + assert.NotNil(t, authError.Error()) + + rawResult := authError.Response.GetRawResult() + statusCode := authError.Response.GetStatusCode() + assert.Equal(t, string(expectedResponse), authError.Error()) + assert.Equal(t, expectedResponse, rawResult) + assert.NotNil(t, rawResult) + assert.Equal(t, http.StatusGatewayTimeout, statusCode) + assert.Empty(t, token) +} + +func TestMCSPRequestTokenError1(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + authenticator, err := NewMCSPAuthenticatorBuilder(). + SetApiKey(mcspAuthMockApiKey). + SetURL(mcspAuthMockURL). + Build() + assert.Nil(t, err) + assert.NotNil(t, authenticator) + + // Now forcibly clear the ApiKey field so we can test an error condition. + authenticator.ApiKey = "" + + _, err = authenticator.RequestToken() + assert.NotNil(t, err) + t.Logf("Expected error: %s", err.Error()) +} + +func TestMCSPRequestTokenError2(t *testing.T) { + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + // Force an error while resolving the service URL. + auth := &MCSPAuthenticator{ + ApiKey: mcspAuthMockApiKey, + URL: "123:badpath", + } + + mcspToken, err := auth.RequestToken() + assert.NotNil(t, err) + assert.Nil(t, mcspToken) + t.Logf("Expected error: %s\n", err.Error()) +} + +func TestMCSPNewTokenDataError1(t *testing.T) { + tokenData, err := newMCSPTokenData(nil) + assert.NotNil(t, err) + assert.Nil(t, tokenData) + t.Logf("Expected error: %s\n", err.Error()) +} + +// In order to test with a live token server, create file "mcsptest.env" in the project root. +// It should look like this: +// +// MCSPTEST1_AUTH_URL= e.g. https://iam.platform.test.saas.ibm.com +// MCSPTEST1_AUTH_TYPE=mcsp +// MCSPTEST1_APIKEY= +// +// Then comment out the "t.Skip()" line below, then run these commands: +// +// cd core +// go test -v -tags=auth -run=TestMCSPLiveTokenServer +// +// To trace request/response messages, change "mcspAuthTestLogLevel" above to be "LevelDebug". +func TestMCSPLiveTokenServer(t *testing.T) { + t.Skip("Skipping MCSP integration test...") + + GetLogger().SetLogLevel(mcspAuthTestLogLevel) + + var request *http.Request + var err error + var authHeader1 string + var authHeader2 string + + // Get an mcsp authenticator from the environment. + t.Setenv("IBM_CREDENTIALS_FILE", "../mcsptest.env") + + auth, err := GetAuthenticatorFromEnvironment("mcsptest1") + assert.Nil(t, err) + assert.NotNil(t, auth) + + // Verify that it is in fact an MCSPAuthenticator instance. + _, ok := auth.(*MCSPAuthenticator) + assert.Equal(t, true, ok) + + // Create a new Request object. + builder, err := NewRequestBuilder("GET").ResolveRequestURL("https://localhost/placeholder/url", "", nil) + assert.Nil(t, err) + assert.NotNil(t, builder) + request, _ = builder.Build() + assert.NotNil(t, request) + + // Authenticate the request and verify that the Authorization header was added. + err = auth.Authenticate(request) + assert.Nil(t, err) + authHeader1 = request.Header.Get("Authorization") + assert.NotEmpty(t, authHeader1) + assert.True(t, strings.HasPrefix(authHeader1, "Bearer ")) + t.Logf("Authorization: %s\n", authHeader1) + + // Build a new request and then authenticate that and verify. + request, _ = builder.Build() + assert.NotNil(t, request) + err = auth.Authenticate(request) + assert.Nil(t, err) + authHeader2 = request.Header.Get("Authorization") + assert.NotEmpty(t, authHeader2) + assert.True(t, strings.HasPrefix(authHeader2, "Bearer ")) + + // Make sure the auth header values from the two requests are the same. + // We should have just used the cached access token in the second request. + assert.Equal(t, authHeader1, authHeader2) + +} diff --git a/resources/my-credentials.env b/resources/my-credentials.env index 2987da0b..596d720f 100644 --- a/resources/my-credentials.env +++ b/resources/my-credentials.env @@ -85,6 +85,12 @@ SERVICE9_CLIENT_ID=user1 SERVICE9_CLIENT_SECRET=secret1 SERVICE9_AUTH_URL=https://iam.refresh-token.com +# MCSP auth +SERVICE10_AUTH_TYPE=mcsp +SERVICE10_APIKEY=my-api-key +SERVICE10_AUTH_URL=https://mcsp.ibm.com +SERVICE10_AUTH_DISABLE_SSL=true + # EQUAL service exercises value with = in them EQUAL_SERVICE_URL==https:/my=host.com/my=service/api EQUAL_SERVICE_APIKEY==my=api=key=