diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a33b2393..775e7125 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -3,7 +3,7 @@ # semantic-release is also run to create a new release (if # warranted by the new commits being built). -name: Build/Test +name: build on: push: @@ -16,7 +16,7 @@ on: jobs: detect-secrets: if: "!contains(github.event.head_commit.message, '[skip ci]')" - name: Detect-Secrets + name: detect-secrets runs-on: ubuntu-latest steps: @@ -38,8 +38,8 @@ jobs: detect-secrets -v audit --report --fail-on-unaudited --fail-on-live --fail-on-audited-real .secrets.baseline build: + name: build-test (java ${{matrix.java-version}}) needs: detect-secrets - name: Build/Test (Java ${{matrix.java-version}}) runs-on: ubuntu-latest strategy: matrix: @@ -60,8 +60,8 @@ jobs: run: mvn -B clean package create-release: + name: semantic-release needs: build - name: Semantic-Release if: "github.ref_name == 'main' && github.event_name != 'pull_request'" runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 79ede6c8..65b12ac6 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -3,7 +3,7 @@ # - building and publishing javadocs to the git repository. # It is triggered when a new release is created. -name: Publish +name: publish on: release: @@ -13,7 +13,7 @@ on: jobs: publish: - name: Publish Release + name: publish-release runs-on: ubuntu-latest steps: diff --git a/.secrets.baseline b/.secrets.baseline index 379642c0..f3feac60 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": "package-lock.json|^.secrets.baseline$", "lines": null }, - "generated_at": "2025-01-08T20:47:36Z", + "generated_at": "2025-05-27T21:34:41Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -80,7 +80,7 @@ "hashed_secret": "91dfd9ddb4198affc5c194cd8ce6d338fde470e2", "is_secret": false, "is_verified": false, - "line_number": 73, + "line_number": 74, "type": "Secret Keyword", "verified_result": null }, @@ -88,7 +88,7 @@ "hashed_secret": "4f51cde3ac0a5504afa4bc06859b098366592c19", "is_secret": false, "is_verified": false, - "line_number": 222, + "line_number": 223, "type": "Secret Keyword", "verified_result": null }, @@ -96,7 +96,7 @@ "hashed_secret": "e87559ed7decb62d0733ae251ae58d42a55291d8", "is_secret": false, "is_verified": false, - "line_number": 224, + "line_number": 225, "type": "Secret Keyword", "verified_result": null }, @@ -104,7 +104,7 @@ "hashed_secret": "12f4a68ed3d0863e56497c9cdb1e2e4e91d5cb68", "is_secret": false, "is_verified": false, - "line_number": 288, + "line_number": 289, "type": "Secret Keyword", "verified_result": null }, @@ -112,7 +112,7 @@ "hashed_secret": "c837b75d7cd93ef9c2243ca28d6e5156259fd253", "is_secret": false, "is_verified": false, - "line_number": 292, + "line_number": 293, "type": "Secret Keyword", "verified_result": null }, @@ -120,7 +120,7 @@ "hashed_secret": "98635b2eaa2379f28cd6d72a38299f286b81b459", "is_secret": false, "is_verified": false, - "line_number": 526, + "line_number": 529, "type": "Secret Keyword", "verified_result": null }, @@ -128,7 +128,7 @@ "hashed_secret": "47fcf185ee7e15fe05cae31fbe9e4ebe4a06a40d", "is_secret": false, "is_verified": false, - "line_number": 629, + "line_number": 729, "type": "Secret Keyword", "verified_result": null } @@ -146,7 +146,7 @@ "hashed_secret": "d4c3d66fd0c38547a3c7a4c6bdc29c36911bc030", "is_secret": false, "is_verified": false, - "line_number": 47, + "line_number": 48, "type": "Secret Keyword", "verified_result": null }, @@ -154,7 +154,7 @@ "hashed_secret": "8318df9ecda039deac9868adf1944a29a95c7114", "is_secret": false, "is_verified": false, - "line_number": 50, + "line_number": 51, "type": "Secret Keyword", "verified_result": null }, @@ -162,7 +162,7 @@ "hashed_secret": "9a66213cc16d178fdbf9f4da6b7bd92497fda404", "is_secret": false, "is_verified": false, - "line_number": 56, + "line_number": 57, "type": "Secret Keyword", "verified_result": null } @@ -245,6 +245,16 @@ "verified_result": null } ], + "src/test/java/com/ibm/cloud/sdk/core/test/security/MCSPV2AuthenticatorTest.java": [ + { + "hashed_secret": "d3576f76725ff2040fe425790fe8b5ccf61994a2", + "is_secret": false, + "is_verified": false, + "line_number": 63, + "type": "Secret Keyword", + "verified_result": null + } + ], "src/test/java/com/ibm/cloud/sdk/core/util/CredentialUtilsTest.java": [ { "hashed_secret": "1beb7496ebbe82c61151be093956d83dac625c13", @@ -301,6 +311,24 @@ "verified_result": null } ], + "src/test/resources/mcspv2_token.json": [ + { + "hashed_secret": "3c1e5e4bcfb74c7ba031f00ee4a1ebe685fbf57a", + "is_secret": false, + "is_verified": false, + "line_number": 2, + "type": "JSON Web Token", + "verified_result": null + }, + { + "hashed_secret": "e196c8eb696e0f04be7eb863abbf17a8b2adf993", + "is_secret": false, + "is_verified": false, + "line_number": 2, + "type": "SoftLayer Credentials", + "verified_result": null + } + ], "src/test/resources/my-credentials.env": [ { "hashed_secret": "edbd5e119f94badb9f99a67ac6ff4c7a5204ad61", @@ -389,6 +417,24 @@ "verified_result": null } ], + "src/test/resources/refreshed_mcspv2_token.json": [ + { + "hashed_secret": "d9b5dacb7cabd9bb3369f1cc45e237d9658d5efc", + "is_secret": false, + "is_verified": false, + "line_number": 2, + "type": "JSON Web Token", + "verified_result": null + }, + { + "hashed_secret": "e196c8eb696e0f04be7eb863abbf17a8b2adf993", + "is_secret": false, + "is_verified": false, + "line_number": 2, + "type": "SoftLayer Credentials", + "verified_result": null + } + ], "src/test/resources/vcap_services.json": [ { "hashed_secret": "0ee6f3a69b36c1bcac73c25350a7414a53397ecd", diff --git a/Authentication.md b/Authentication.md index 6aa35803..ea2df1dd 100644 --- a/Authentication.md +++ b/Authentication.md @@ -7,7 +7,8 @@ The java-sdk-core project supports the following types of authentication: - Container Authentication - VPC Instance Authentication - Cloud Pak for Data Authentication -- Multi-Cloud Saas Platform (MCSP) Authentication +- Multi-Cloud Saas Platform (MCSP) V1 Authentication +- Multi-Cloud Saas Platform (MCSP) V2 Authentication - No Authentication (for testing) The SDK user configures the appropriate type of authentication for use with service instances. @@ -575,11 +576,11 @@ ExampleService service = ExampleService.newInstance("example_service"); ``` -## Multi-Cloud Saas Platform (MCSP) Authentication +## Multi-Cloud Saas Platform (MCSP) V1 Authentication The `MCSPAuthenticator` can be used in scenarios where an application needs to interact with an IBM Cloud service that has been deployed to a non-IBM Cloud environment (e.g. AWS). -It accepts a user-supplied apikey and performs the necessary interactions with the -Multi-Cloud Saas Platform token service to obtain a suitable MCSP access token (a bearer token) +It accepts a user-supplied apikey and invokes the Multi-Cloud Saas Platform token service's +`POST /siusermgr/api/1.0/apikeys/token` operation to obtain a suitable MCSP access token (a bearer token) for the specified apikey. The authenticator will also obtain a new bearer token when the current token expires. The bearer token is then added to each outbound request in the `Authorization` header in the @@ -643,6 +644,107 @@ ExampleService service = ExampleService.newInstance("example_service"); ``` +## Multi-Cloud Saas Platform (MCSP) V2 Authentication +The `MCSPV2Authenticator` can be used in scenarios where an application needs to +interact with an IBM Cloud service that has been deployed to a non-IBM Cloud environment (e.g. AWS). +It accepts a user-supplied apikey and invokes the Multi-Cloud Saas Platform token service's +`POST /api/2.0/{scopeCollectionType}/{scopeId}/apikeys/token` operation to obtain a suitable MCSP access token (a bearer token) +for the specified apikey. +The authenticator will also obtain a new bearer token when the current token expires. +The bearer token is then added to each outbound request in the `Authorization` header in the +form: +``` + Authorization: Bearer +``` + +### Properties + +- apikey: (required) the apikey to be used to obtain an MCSP access token. + +- url: (required) The URL representing the MCSP token service endpoint's base URL string. Do not include the +operation path (e.g. `/siusermgr/api/1.0/apikeys/token`) as part of this property's value. + +- scopeCollectionType: (required) The scope collection type of item(s). +The valid values are: `accounts`, `subscriptions`, `services`. + +- scopeId: (required) The scope identifier of item(s). + +- includeBuiltinActions: (optional) A flag to include builtin actions in the `actions` claim in the MCSP token (default: false). + +- includeCustomActions: (optional) A flag to include custom actions in the `actions` claim in the MCSP token (default: false). + +- includeRoles: (optional) A flag to include the `roles` claim in the MCSP token (default: true). + +- prefixRoles: (optional) A flag to add a prefix with the scope level where +the role is defined in the `roles` claim (default: false). + +- callerExtClaim: (optional) A map containing keys and values to be injected into the returned access token +as the `callerExt` claim. The keys used in this map must be enabled in the apikey by setting the +`callerExtClaimNames` property when the apikey is created. +This property is typically only used in scenarios involving an apikey with identityType `SERVICEID`. + +- disableSSLVerification: (optional) A flag that indicates whether verification of the server's SSL +certificate should be disabled or not. The default value is `false`. + +- headers: (optional) A set of key/value pairs that will be sent as HTTP headers in requests +made to the MCSP token service. + +### Usage Notes +- When constructing an MCSPV2Authenticator instance, the apikey, url, scopeCollectionType, and scopeId properties are required. + +- If you specify the callerExtClaim map, the keys used in the map must have been previously enabled in the apikey +by setting the `callerExtClaimNames` property when you created the apikey. +The entries contained in this map will appear in the `callerExt` field (claim) of the returned access token. + +- The authenticator will invoke the token server's `POST /api/2.0/{scopeCollectionType}/{scopeId}/apikeys/token` operation to +exchange the apikey for an MCSP access token (the bearer token). + +### Programming example +```java +import com.ibm.cloud.sdk.core.security.MCSPV2Authenticator; +import .ExampleService.v1.ExampleService; +... +Map callerExtClaim = Map.of("productID", "prod-123"); + +// Create the authenticator. +MCSPV2Authenticator authenticator = new MCSPV2Authenticator.Builder() + .apikey("myapikey") + .url("https://example.mcspv2.token-exchange.com") + .scopeCollectionType("accounts") + .scopeId("20250519-2128-3755-60b3-103e01c509e8") + .includeBuiltinActions(true) + .callerExtClaim(callerExtClaim) + .build(); + +// Create the service instance. +ExampleService service = new ExampleService(ExampleService.DEFAULT_SERVICE_NAME, authenticator); + +// 'service' can now be used to invoke operations. +``` + +### Configuration example +External configuration: +``` +export EXAMPLE_SERVICE_AUTH_TYPE=mcspv2 +export EXAMPLE_SERVICE_APIKEY=myapikey +export EXAMPLE_SERVICE_AUTH_URL=https://example.mcspv2.token-exchange.com +export EXAMPLE_SERVICE_SCOPE_COLLECTION_TYPE=accounts +export EXAMPLE_SERVICE_SCOPE_ID=20250519-2128-3755-60b3-103e01c509e8 +export EXAMPLE_SERVICE_INCLUDE_BUILTIN_ACTIONS=true +export EXAMPLE_SERVICE_CALLER_EXT_CLAIM={"productID":"prod-123"} +``` +Application code: +```java +import .ExampleService.v1.ExampleService; +... + +// Create the service instance. +ExampleService service = ExampleService.newInstance("example_service"); + +// 'service' can now be used to invoke operations. +``` + + ## No Auth Authentication The `NoAuthAuthenticator` is a placeholder authenticator which performs no actual authentication function. It can be used in situations where authentication needs to be bypassed, perhaps while developing diff --git a/debug-logging.properties b/debug-logging.properties index 605126a1..8b6e0911 100644 --- a/debug-logging.properties +++ b/debug-logging.properties @@ -1,12 +1,12 @@ # -# Copyright 2024 IBM Corporation. +# Copyright 2024, 2025 IBM Corporation. # SPDX-License-Identifier: Apache2.0 # # This file contains a java.util.logging configuration that enables debug # logging (level = FINE) in the Java SDK core library. # -# To use this file, you can add the "-Djava.util.logging.config.file=logging.properties" +# To use this file, you can add the "-Djava.util.logging.config.file=debug-logging.properties" # option to your java command line. # For more information on java.util.logging, please see: # https://docs.oracle.com/en/java/javase/11/core/java-logging-overview.html diff --git a/src/main/java/com/ibm/cloud/sdk/core/security/Authenticator.java b/src/main/java/com/ibm/cloud/sdk/core/security/Authenticator.java index a871f349..68cfa2f2 100644 --- a/src/main/java/com/ibm/cloud/sdk/core/security/Authenticator.java +++ b/src/main/java/com/ibm/cloud/sdk/core/security/Authenticator.java @@ -34,6 +34,7 @@ public interface Authenticator { String AUTHTYPE_CONTAINER = "container"; String AUTHTYPE_VPC = "vpc"; String AUTHTYPE_MCSP = "mcsp"; + String AUTHTYPE_MCSPV2 = "mcspv2"; /** * Constants which define the names of external config propreties (credential file, environment variable, etc.). @@ -59,6 +60,13 @@ public interface Authenticator { String PROPNAME_IAM_PROFILE_ID = "IAM_PROFILE_ID"; String PROPNAME_IAM_PROFILE_NAME = "IAM_PROFILE_NAME"; String PROPNAME_IAM_ACCOUNT_ID = "IAM_ACCOUNT_ID"; + String PROPNAME_SCOPE_COLLECTION_TYPE = "SCOPE_COLLECTION_TYPE"; + String PROPNAME_SCOPE_ID = "SCOPE_ID"; + String PROPNAME_INCLUDE_BUILTIN_ACTIONS = "INCLUDE_BUILTIN_ACTIONS"; + String PROPNAME_INCLUDE_CUSTOM_ACTIONS = "INCLUDE_CUSTOM_ACTIONS"; + String PROPNAME_INCLUDE_ROLES = "INCLUDE_ROLES"; + String PROPNAME_PREFIX_ROLES = "PREFIX_ROLES"; + String PROPNAME_CALLER_EXT_CLAIM = "CALLER_EXT_CLAIM"; /** * Validates the current set of configuration information in the Authenticator. diff --git a/src/main/java/com/ibm/cloud/sdk/core/security/AuthenticatorBase.java b/src/main/java/com/ibm/cloud/sdk/core/security/AuthenticatorBase.java index 322d9b4a..b8687c1e 100644 --- a/src/main/java/com/ibm/cloud/sdk/core/security/AuthenticatorBase.java +++ b/src/main/java/com/ibm/cloud/sdk/core/security/AuthenticatorBase.java @@ -35,6 +35,8 @@ public class AuthenticatorBase { "The %s property must be a valid integer but was %s."; public static final String ERRORMSG_ACCOUNTID_PROP_ERROR = "iamAccountId must be specified if and only if iamProfileName is specified"; + public static final String ERRORMSG_PROP_INVALID_BOOL = + "The %s property must be a valid boolean but was '%s'. Valid values are 'true' and 'false'."; /** * Returns a "Basic" Authorization header value for the specified username and password. diff --git a/src/main/java/com/ibm/cloud/sdk/core/security/ConfigBasedAuthenticatorFactory.java b/src/main/java/com/ibm/cloud/sdk/core/security/ConfigBasedAuthenticatorFactory.java index 12385c8e..c20fc9fa 100644 --- a/src/main/java/com/ibm/cloud/sdk/core/security/ConfigBasedAuthenticatorFactory.java +++ b/src/main/java/com/ibm/cloud/sdk/core/security/ConfigBasedAuthenticatorFactory.java @@ -109,6 +109,8 @@ protected static Authenticator createAuthenticator(Map props) { authenticator = VpcInstanceAuthenticator.fromConfiguration(props); } else if (authType.equalsIgnoreCase(Authenticator.AUTHTYPE_MCSP)) { authenticator = MCSPAuthenticator.fromConfiguration(props); + } else if (authType.equalsIgnoreCase(Authenticator.AUTHTYPE_MCSPV2)) { + authenticator = MCSPV2Authenticator.fromConfiguration(props); } else if (authType.equalsIgnoreCase(Authenticator.AUTHTYPE_NOAUTH)) { authenticator = new NoAuthAuthenticator(props); } else { diff --git a/src/main/java/com/ibm/cloud/sdk/core/security/MCSPAuthenticator.java b/src/main/java/com/ibm/cloud/sdk/core/security/MCSPAuthenticator.java index 7589d9ea..8c1928f0 100644 --- a/src/main/java/com/ibm/cloud/sdk/core/security/MCSPAuthenticator.java +++ b/src/main/java/com/ibm/cloud/sdk/core/security/MCSPAuthenticator.java @@ -1,5 +1,5 @@ /** - * (C) Copyright IBM Corp. 2023, 2024. + * (C) Copyright IBM Corp. 2023, 2025. * * 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 @@ -34,7 +34,7 @@ */ public class MCSPAuthenticator extends TokenRequestBasedAuthenticator implements Authenticator { - private static final Logger LOG = Logger.getLogger(ContainerAuthenticator.class.getName()); + private static final Logger LOG = Logger.getLogger(MCSPAuthenticator.class.getName()); private static final String OPERATION_PATH = "/siusermgr/api/1.0/apikeys/token"; // Properties specific to an MCSP authenticator. diff --git a/src/main/java/com/ibm/cloud/sdk/core/security/MCSPToken.java b/src/main/java/com/ibm/cloud/sdk/core/security/MCSPToken.java index 9679ea1f..74a0693e 100644 --- a/src/main/java/com/ibm/cloud/sdk/core/security/MCSPToken.java +++ b/src/main/java/com/ibm/cloud/sdk/core/security/MCSPToken.java @@ -76,6 +76,33 @@ public MCSPToken(MCSPTokenResponse response) { } } + /** + * This ctor will extract the access token from the specified MCSPV2TokenResponse instance, + * and compute the refresh time as "80% of the timeToLive added to the issued-at time". + * This means that we'll trigger the acquisition of a new token shortly before it is set to expire. + * @param response the MCSPTokenResponse instance + */ + public MCSPToken(MCSPV2TokenResponse response) { + super(); + this.accessToken = response.getToken(); + + // To compute the expiration time, we'll need to crack open the accessToken value + // which is a JWT (Json Web Token) instance. + JsonWebToken jwt = new JsonWebToken(this.accessToken); + + Long iat = jwt.getPayload().getIssuedAt(); + Long exp = jwt.getPayload().getExpiresAt(); + + if (iat != null && exp != null) { + long ttl = exp - iat; + + this.expirationTime = exp; + this.refreshTime = iat + (long) (0.8 * ttl); + } else { + throw new RuntimeException("Properties 'iat' and 'exp' MUST be present within the encoded access token"); + } + } + /** * Returns true iff this object does not hold a valid access token or has one which has crossed our refresh * time. This method also updates the refresh time if it determines the token needs to be refreshed to diff --git a/src/main/java/com/ibm/cloud/sdk/core/security/MCSPV2Authenticator.java b/src/main/java/com/ibm/cloud/sdk/core/security/MCSPV2Authenticator.java new file mode 100644 index 00000000..b60ad0b0 --- /dev/null +++ b/src/main/java/com/ibm/cloud/sdk/core/security/MCSPV2Authenticator.java @@ -0,0 +1,571 @@ +/** + * (C) Copyright IBM Corp. 2025.. + * + * 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. + */ + +package com.ibm.cloud.sdk.core.security; + +import java.io.InputStream; +import java.lang.reflect.Type; +import java.net.Proxy; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.apache.commons.lang3.BooleanUtils; +import org.apache.commons.lang3.StringUtils; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import com.ibm.cloud.sdk.core.http.HttpHeaders; +import com.ibm.cloud.sdk.core.http.HttpMediaType; +import com.ibm.cloud.sdk.core.http.RequestBuilder; +import com.ibm.cloud.sdk.core.util.GsonSingleton; +import com.ibm.cloud.sdk.core.util.RequestUtils; + +import okhttp3.OkHttpClient; + +/** + * This class provides an Authenticator implementation for the Multi-Cloud Saas + * Platform (MCSP) v2 environment. + * This authenticator invokes the MCSP v2 token-exchange operation + * (POST /api/2.0/{scopeCollectionType}/{scopeId}/apikeys/token) to obtain an access token for an apikey, + * and adds the access token to requests via an Authorization header of the form: + * "Authorization: Bearer <access-token>" + * When the access token expires, a new access token will be fetched. + */ +public class MCSPV2Authenticator extends TokenRequestBasedAuthenticatorImmutable + implements Authenticator { + private static final Logger LOG = Logger.getLogger(MCSPV2Authenticator.class.getName()); + private static final String OPERATION_PATH = "/api/2.0/{scopeCollectionType}/{scopeId}/apikeys/token"; + + // Properties specific to an MCSP authenticator. + private String apikey; + private String url; + private String scopeCollectionType; + private String scopeId; + private boolean includeBuiltinActions; + private boolean includeCustomActions; + private boolean includeRoles; + private boolean prefixRoles; + private Map callerExtClaim; + + /** + * This Builder class is used to construct MCSPV2Authenticator instances. + */ + public static class Builder { + private String apikey; + private String url; + private String scopeCollectionType; + private String scopeId; + private boolean includeBuiltinActions; + private boolean includeCustomActions; + private boolean includeRoles; + private boolean prefixRoles; + private Map callerExtClaim; + private boolean disableSSLVerification; + private Map headers; + private Proxy proxy; + private okhttp3.Authenticator proxyAuthenticator; + private OkHttpClient client; + + /** + * Constructs an empty Builder. + */ + public Builder() { + // "includeRoles" default value should be true. + this.includeRoles = true; + } + + /** + * Builder ctor which copies config from an existing authenticator instance. + * @param obj + */ + private Builder(MCSPV2Authenticator obj) { + this.apikey = obj.apikey; + this.url = obj.getURL(); + this.scopeCollectionType = obj.getScopeCollectionType(); + this.scopeId = obj.getScopeId(); + this.includeBuiltinActions = obj.includeBuiltinActions(); + this.includeCustomActions = obj.includeCustomActions(); + this.includeRoles = obj.includeRoles(); + this.prefixRoles = obj.prefixRoles(); + this.callerExtClaim = obj.getCallerExtClaim(); + this.disableSSLVerification = obj.getDisableSSLVerification(); + this.headers = obj.getHeaders(); + this.proxy = obj.getProxy(); + this.proxyAuthenticator = obj.getProxyAuthenticator(); + this.client = obj.getClient(); + } + + /** + * Constructs a new instance of MCSPAuthenticator from the builder's + * configuration. + * + * @return the MCSPAuthenticator instance + */ + public MCSPV2Authenticator build() { + return new MCSPV2Authenticator(this); + } + + /** + * Sets the apikey property. + * + * @param apikey the apikey to use when retrieving an access token + * @return the Builder + */ + public Builder apikey(String apikey) { + this.apikey = apikey; + return this; + } + + /** + * Sets the url property. + * + * @param url the base url to use with the MCSP token service + * @return the Builder + */ + public Builder url(String url) { + this.url = url; + return this; + } + + /** + * Sets the scopeCollectionType property. + * + * @param scopeCollectionType the scope collection type of item(s). Valid values are: + *
    + *
  • accounts
  • + *
  • subscriptions
  • + *
  • services
  • + *
+ * + * @return the Builder + */ + public Builder scopeCollectionType(String scopeCollectionType) { + this.scopeCollectionType = scopeCollectionType; + return this; + } + + /** + * Sets the scopeId property. + * + * @param scopeId the scope identifier of item(s) + * @return the Builder + */ + public Builder scopeId(String scopeId) { + this.scopeId = scopeId; + return this; + } + + /** + * Sets the includeBuiltinActions property. + * + * @param includeBuiltinActions a flag to include builtin actions in the "actions" claim in the + * MCSP access token (default: false). + * + * @return the Builder + */ + public Builder includeBuiltinActions(boolean includeBuiltinActions) { + this.includeBuiltinActions = includeBuiltinActions; + return this; + } + + /** + * Sets the includeCustomActions property. + * + * @param includeCustomActions a flag to include custom actions in the "actions" claim in the + * MCSP access token (default: false). + * + * @return the Builder + */ + public Builder includeCustomActions(boolean includeCustomActions) { + this.includeCustomActions = includeCustomActions; + return this; + } + + /** + * Sets the includeRoles property. + * + * @param includeRoles a flag to include the "roles" claim in the MCSP access token (default: true). + * + * @return the Builder + */ + public Builder includeRoles(boolean includeRoles) { + this.includeRoles = includeRoles; + return this; + } + + /** + * Sets the prefixRoles property. + * + * @param prefixRoles a flag to add a prefix with the scope level where the role is defined in the + * "roles" claim (default: false). + * + * @return the Builder + */ + public Builder prefixRoles(boolean prefixRoles) { + this.prefixRoles = prefixRoles; + return this; + } + + /** + * Sets the callerExtClaim property. + * + * @param callerExtClaim a A map containing keys and values to be injected into the access token as the + * "callerExt" claim. The keys used in this map must be enabled in the apikey by setting the + * "callerExtClaimNames" property when the apikey is created. + * This property is typically only used in scenarios involving an apikey with identityType `SERVICEID`. + * + * @return the Builder + */ + public Builder callerExtClaim(Map callerExtClaim) { + this.callerExtClaim = callerExtClaim; + return this; + } + + /** + * Sets the disableSSLVerification property. + * + * @param disableSSLVerification a boolean flag indicating whether or not SSL + * verification should be disabled when + * interacting with the MCSP token service + * @return the Builder + */ + public Builder disableSSLVerification(boolean disableSSLVerification) { + this.disableSSLVerification = disableSSLVerification; + return this; + } + + /** + * Sets the headers property. + * + * @param headers the set of custom headers to include in requests sent to the + * MCSP token service + * @return the Builder + */ + public Builder headers(Map headers) { + this.headers = headers; + return this; + } + + /** + * Sets the proxy property. + * + * @param proxy the java.net.Proxy instance to be used when interacting with the + * MCSP token server + * @return the Builder + */ + public Builder proxy(Proxy proxy) { + this.proxy = proxy; + return this; + } + + /** + * Sets the proxyAuthenticator property. + * + * @param proxyAuthenticator the okhttp3.Authenticator instance to be used with + * the proxy when interacting with the MCSP token + * service + * @return the Builder + */ + public Builder proxyAuthenticator(okhttp3.Authenticator proxyAuthenticator) { + this.proxyAuthenticator = proxyAuthenticator; + return this; + } + + /** + * Sets the client property. + * + * @param client the OkHttpClient instance that should be used by the authenticator + * when interacting with the MCSP token service + * @return the Builder + */ + public Builder client(OkHttpClient client) { + this.client = client; + return this; + } + } + + /** + * The default ctor is "hidden" to force the use of the non-default ctors. + */ + protected MCSPV2Authenticator() { + setUserAgent(RequestUtils.buildUserAgent("mcspv2-authenticator")); + + // "includeRoles" default value should be true. + this.includeRoles = true; + } + + /** + * Constructs an MCSPV2Authenticator instance from the configuration contained in + * "builder". + * + * @param builder the Builder instance containing the configuration to be used + */ + protected MCSPV2Authenticator(Builder builder) { + this(); + this.apikey = builder.apikey; + this.url = builder.url; + this.scopeCollectionType = builder.scopeCollectionType; + this.scopeId = builder.scopeId; + this.includeBuiltinActions = builder.includeBuiltinActions; + this.includeCustomActions = builder.includeCustomActions; + this.includeRoles = builder.includeRoles; + this.prefixRoles = builder.prefixRoles; + this.callerExtClaim = builder.callerExtClaim; + this._setDisableSSLVerification(builder.disableSSLVerification); + this._setHeaders(builder.headers); + this._setProxy(builder.proxy); + this._setProxyAuthenticator(builder.proxyAuthenticator); + this._setClient(builder.client); + + this.validate(); + } + + /** + * Returns a new Builder instance pre-loaded with the configuration from "this". + * + * @return the Builder instance + */ + public Builder newBuilder() { + return new Builder(this); + } + + /** + * Construct an MCSPV2Authenticator instance using properties retrieved from "config". + * + * @param config a Map containing the configuration properties + * + * @return the MCSPV2Authenticator instance + */ + public static MCSPV2Authenticator fromConfiguration(Map config) { + + // Initialize the builder first with the required properties. + Builder builder = new Builder() + .apikey(config.get(PROPNAME_APIKEY)) + .url(config.get(PROPNAME_URL)) + .scopeCollectionType(config.get(PROPNAME_SCOPE_COLLECTION_TYPE)) + .scopeId(config.get(PROPNAME_SCOPE_ID)); + + // Now add the optional properties to the builder. + String strValue; + Boolean bool; + + strValue = config.get(PROPNAME_INCLUDE_BUILTIN_ACTIONS); + if (StringUtils.isNotEmpty(strValue)) { + bool = BooleanUtils.toBooleanObject(strValue); + if (bool == null) { + throw new IllegalArgumentException( + String.format(ERRORMSG_PROP_INVALID_BOOL, PROPNAME_INCLUDE_BUILTIN_ACTIONS, strValue)); + } + builder.includeBuiltinActions(bool.booleanValue()); + } + + strValue = config.get(PROPNAME_INCLUDE_CUSTOM_ACTIONS); + if (StringUtils.isNotEmpty(strValue)) { + bool = BooleanUtils.toBooleanObject(strValue); + if (bool == null) { + throw new IllegalArgumentException( + String.format(ERRORMSG_PROP_INVALID_BOOL, PROPNAME_INCLUDE_CUSTOM_ACTIONS, strValue)); + } + builder.includeCustomActions(bool.booleanValue()); + } + + strValue = config.get(PROPNAME_INCLUDE_ROLES); + if (StringUtils.isNotEmpty(strValue)) { + bool = BooleanUtils.toBooleanObject(strValue); + if (bool == null) { + throw new IllegalArgumentException( + String.format(ERRORMSG_PROP_INVALID_BOOL, PROPNAME_INCLUDE_ROLES, strValue)); + } + builder.includeRoles(bool.booleanValue()); + } + + strValue = config.get(PROPNAME_PREFIX_ROLES); + if (StringUtils.isNotEmpty(strValue)) { + bool = BooleanUtils.toBooleanObject(strValue); + if (bool == null) { + throw new IllegalArgumentException( + String.format(ERRORMSG_PROP_INVALID_BOOL, PROPNAME_PREFIX_ROLES, strValue)); + } + builder.prefixRoles(bool.booleanValue()); + } + + strValue = config.get(PROPNAME_DISABLE_SSL); + if (StringUtils.isNotEmpty(strValue)) { + bool = BooleanUtils.toBooleanObject(strValue); + if (bool == null) { + throw new IllegalArgumentException( + String.format(ERRORMSG_PROP_INVALID_BOOL, PROPNAME_DISABLE_SSL, strValue)); + } + builder.disableSSLVerification(bool.booleanValue()); + } + + strValue = config.get(PROPNAME_CALLER_EXT_CLAIM); + if (StringUtils.isNotEmpty(strValue)) { + // Unmarshal the string into a generic Map, then set it in the builder. + Gson gson = GsonSingleton.getGsonWithoutPrettyPrinting(); + Type mapType = new TypeToken>() { }.getType(); + Map callerExtClaim = gson.fromJson(strValue, mapType); + + builder.callerExtClaim(callerExtClaim); + } + + return builder.build(); + } + + /** + * Validates the configuration. + */ + @Override + public void validate() { + if (StringUtils.isEmpty(this.getURL())) { + throw new IllegalArgumentException(String.format(ERRORMSG_PROP_MISSING, "url")); + } + + if (StringUtils.isEmpty(this.apikey)) { + throw new IllegalArgumentException(String.format(ERRORMSG_PROP_MISSING, "apikey")); + } + + if (StringUtils.isEmpty(this.scopeCollectionType)) { + throw new IllegalArgumentException(String.format(ERRORMSG_PROP_MISSING, "scopeCollectionType")); + } + + if (StringUtils.isEmpty(this.scopeId)) { + throw new IllegalArgumentException(String.format(ERRORMSG_PROP_MISSING, "scopeId")); + } + } + + @Override + public String authenticationType() { + return Authenticator.AUTHTYPE_MCSPV2; + } + + /** + * @return the apikey property configured on this Authenticator. + */ + public String getApiKey() { + return this.apikey; + } + + /** + * @return the url property configured on this Authenticator. + */ + public String getURL() { + return this.url; + } + + /** + * @return the scopeCollectionType property configured on this Authenticator. + */ + public String getScopeCollectionType() { + return this.scopeCollectionType; + } + + /** + * @return the scopeId property configured on this Authenticator. + */ + public String getScopeId() { + return this.scopeId; + } + + /** + * @return the includeBuiltinActions property configured on this Authenticator. + */ + public boolean includeBuiltinActions() { + return this.includeBuiltinActions; + } + + /** + * @return the includeCustomActions property configured on this Authenticator. + */ + public boolean includeCustomActions() { + return this.includeCustomActions; + } + + /** + * @return the includeRoles property configured on this Authenticator. + */ + public boolean includeRoles() { + return this.includeRoles; + } + + /** + * @return the prefixRoles property configured on this Authenticator. + */ + public boolean prefixRoles() { + return this.prefixRoles; + } + + /** + * @return the callerExtClaim property configured on this Authenticator. + */ + public Map getCallerExtClaim() { + return this.callerExtClaim; + } + + /** + * Fetches an access token for the current authenticator configuration. + * + * @return an MCSPToken instance that contains the access token + */ + @Override + public MCSPToken requestToken() { + // Construct a POST request to retrieve the access token from the server. + Map pathParams = new HashMap<>(); + pathParams.put("scopeCollectionType", this.getScopeCollectionType()); + pathParams.put("scopeId", this.getScopeId()); + RequestBuilder builder = RequestBuilder + .post(RequestBuilder.resolveRequestUrl(this.getURL(), OPERATION_PATH, pathParams)); + + // Add the request headers. + builder.header(HttpHeaders.ACCEPT, HttpMediaType.APPLICATION_JSON); + builder.header(HttpHeaders.CONTENT_TYPE, HttpMediaType.APPLICATION_JSON); + builder.header(HttpHeaders.USER_AGENT, getUserAgent()); + + // Add the query params. + builder.query("includeBuiltinActions", this.includeBuiltinActions()); + builder.query("includeCustomActions", this.includeCustomActions()); + builder.query("includeRoles", this.includeRoles()); + builder.query("prefixRolesWithDefinitionScope", this.prefixRoles()); + + // Build the request body and set it on the request builder. + MCSPV2RequestBody requestBody = new MCSPV2RequestBody(this.getApiKey(), this.getCallerExtClaim()); + builder.bodyContent(HttpMediaType.APPLICATION_JSON, requestBody, null, (InputStream) null); + + // Invoke the POST request. + MCSPToken token; + try { + LOG.log(Level.FINE, "Invoking MCSPv2 token service operation: POST {0}", builder.toUrl()); + MCSPV2TokenResponse response = invokeRequest(builder, MCSPV2TokenResponse.class); + LOG.log(Level.FINE, "Returned from MCSPv2 token service operation"); + token = new MCSPToken(response); + } catch (Throwable t) { + token = new MCSPToken(t); + LOG.log(Level.FINE, "Exception from MCSPv2 token service operation: ", t); + } + return token; + } + + // This class models the request body supported by the token-exchange operation. + @SuppressWarnings("unused") + private static class MCSPV2RequestBody { + private String apikey; + private Map callerExtClaim; + + MCSPV2RequestBody(String apikey, Map callerExtClaim) { + this.apikey = apikey; + this.callerExtClaim = callerExtClaim; + } + } +} diff --git a/src/main/java/com/ibm/cloud/sdk/core/security/MCSPV2TokenResponse.java b/src/main/java/com/ibm/cloud/sdk/core/security/MCSPV2TokenResponse.java new file mode 100644 index 00000000..94d7e370 --- /dev/null +++ b/src/main/java/com/ibm/cloud/sdk/core/security/MCSPV2TokenResponse.java @@ -0,0 +1,65 @@ +/** + * (C) Copyright IBM Corp. 2025. + * + * 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. + */ + +package com.ibm.cloud.sdk.core.security; + +import com.google.gson.annotations.SerializedName; + +/** + * This class models a response received from the Multi-Cloud Saas Platform + * token server's "POST /api/2.0/{scopeCollectionType}/{scopeId}/apikeys/token" operation. + */ +public class MCSPV2TokenResponse implements TokenServerResponse { + + private String token; + + @SerializedName("token_type") + private String tokenType; + + @SerializedName("expires_in") + private Long expiresIn; + + private Long expiration; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getTokenType() { + return tokenType; + } + + public void setTokenType(String tokenType) { + this.tokenType = tokenType; + } + + public Long getExpiresIn() { + return expiresIn; + } + + public void setExpiresIn(Long expiresIn) { + this.expiresIn = expiresIn; + } + + public Long getExpiration() { + return expiration; + } + + public void setExpiration(Long expiration) { + this.expiration = expiration; + } +} diff --git a/src/main/java/com/ibm/cloud/sdk/core/security/TokenRequestBasedAuthenticatorImmutable.java b/src/main/java/com/ibm/cloud/sdk/core/security/TokenRequestBasedAuthenticatorImmutable.java index 756148b4..98199231 100644 --- a/src/main/java/com/ibm/cloud/sdk/core/security/TokenRequestBasedAuthenticatorImmutable.java +++ b/src/main/java/com/ibm/cloud/sdk/core/security/TokenRequestBasedAuthenticatorImmutable.java @@ -1,5 +1,5 @@ /** - * (C) Copyright IBM Corp. 2024. + * (C) Copyright IBM Corp. 2024, 2025. * * 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 @@ -82,7 +82,7 @@ protected void _setClient(OkHttpClient client) { /** * Returns the OkHttpClient instance to be used when interacting with the token service. - * @return the client instance or null if a client insance has not yet been set + * @return the client instance or null if a client instance has not yet been set */ public OkHttpClient getClient() { return this.client; diff --git a/src/test/java/com/ibm/cloud/sdk/core/test/security/MCSPV2AuthenticatorLiveTest.java b/src/test/java/com/ibm/cloud/sdk/core/test/security/MCSPV2AuthenticatorLiveTest.java new file mode 100644 index 00000000..85afd012 --- /dev/null +++ b/src/test/java/com/ibm/cloud/sdk/core/test/security/MCSPV2AuthenticatorLiveTest.java @@ -0,0 +1,82 @@ +/** + * (C) Copyright IBM Corp. 2025. + * + * 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. + */ + +package com.ibm.cloud.sdk.core.test.security; + +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertTrue; + +import org.testng.annotations.Ignore; +import org.testng.annotations.Test; + +import com.ibm.cloud.sdk.core.http.HttpHeaders; +import com.ibm.cloud.sdk.core.security.Authenticator; +import com.ibm.cloud.sdk.core.security.ConfigBasedAuthenticatorFactory; + +import okhttp3.Request; + +// +// This class contains an integration test that uses the live MCSP v2 token service. +// This test is normally @Ignored to avoid trying to run this during automated builds. +// +// In order to run these tests, create file "mcspv2test.env" in the project root. +// It should look like this: +// +// required properties: +// +// MCSPV2TEST1_AUTH_URL= e.g. https://account-iam.platform.dev.saas.ibm.com +// MCSPV2TEST1_AUTH_TYPE=mcspv2 +// MCSPV2TEST1_APIKEY= +// MCSPV2TEST1_SCOPE_COLLECTION_TYPE=accounts (use any valid collection type value) +// MCSPV2TEST1_SCOPE_ID=global_account (use any valid scope id) +// +// optional properties: +// +// MCSPV2TEST1_INCLUDE_BUILTIN_ACTIONS=true|false +// MCSPV2TEST1_INCLUDE_CUSTOM_ACTIONS=true|false +// MCSPV2TEST1_INCLUDE_ROLES=true|false +// MCSPV2TEST1_PREFIX_ROLES=true|false +// MCSPV2TEST1_CALLER_EXT_CLAIM={"productID":"prod123"} +// +// Then remove/comment-out the @Ignore annotation below and run the method as a TestNG test in eclipse, +// or via command line: +// mvn test -Dtest=MCSPV2AuthenticatorLiveTest -Djava.util.logging.config.file=debug-logging.properties +// +public class MCSPV2AuthenticatorLiveTest { + + @Ignore + @Test + public void testMCSPLiveTokenServer() { + System.setProperty("IBM_CREDENTIALS_FILE", "mcspv2test.env"); + + Authenticator auth1 = ConfigBasedAuthenticatorFactory.getAuthenticator("mcspv2test1"); + assertNotNull(auth1); + + Request.Builder requestBuilder; + + // Perform a test using the "production" IAM token server. + requestBuilder = new Request.Builder().url("https://test.com"); + auth1.authenticate(requestBuilder); + verifyAuthHeader(requestBuilder, "Bearer "); + } + + // Verify the Authorization header in the specified request builder. + private void verifyAuthHeader(Request.Builder builder, String expectedPrefix) { + Request request = builder.build(); + String actualValue = request.header(HttpHeaders.AUTHORIZATION); + assertNotNull(actualValue); + System.out.println("Authorization: " + actualValue); + + assertTrue(actualValue.startsWith(expectedPrefix)); + } +} diff --git a/src/test/java/com/ibm/cloud/sdk/core/test/security/MCSPV2AuthenticatorTest.java b/src/test/java/com/ibm/cloud/sdk/core/test/security/MCSPV2AuthenticatorTest.java new file mode 100644 index 00000000..241292b7 --- /dev/null +++ b/src/test/java/com/ibm/cloud/sdk/core/test/security/MCSPV2AuthenticatorTest.java @@ -0,0 +1,541 @@ +/** + * (C) Copyright IBM Corp. 2025. + * + * 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. + */ + +package com.ibm.cloud.sdk.core.test.security; + +import static com.ibm.cloud.sdk.core.test.TestUtils.loadFixture; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertNotNull; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; +import com.ibm.cloud.sdk.core.http.HttpHeaders; +import com.ibm.cloud.sdk.core.security.Authenticator; +import com.ibm.cloud.sdk.core.security.MCSPV2Authenticator; +import com.ibm.cloud.sdk.core.security.MCSPV2TokenResponse; +import com.ibm.cloud.sdk.core.service.exception.ServiceResponseException; +import com.ibm.cloud.sdk.core.test.BaseServiceUnitTest; +import com.ibm.cloud.sdk.core.util.Clock; +import com.ibm.cloud.sdk.core.util.GsonSingleton; + +import okhttp3.ConnectionSpec; +import okhttp3.Headers; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.mockwebserver.RecordedRequest; + +public class MCSPV2AuthenticatorTest extends BaseServiceUnitTest { + + // Token with issued-at time of 1699026536 and expiration time of 1699033736 + private MCSPV2TokenResponse tokenData; + + // Token with issued-at time of 1699037852 and expiration time of 1699045052 + private MCSPV2TokenResponse refreshedTokenData; + + // The mock server's URL. + private String url; + + private static final String API_KEY = ""; + private static final String AUTH_URL = "https://mcspv2.token-exchange.com"; + private static final String SCOPE_COLLECTION_TYPE = "accounts"; + private static final String SCOPE_ID = "global_accounts"; + + @Override + @BeforeMethod + public void setUp() throws Exception { + super.setUp(); + url = getMockWebServerUrl(); + tokenData = loadFixture("src/test/resources/mcspv2_token.json", MCSPV2TokenResponse.class); + refreshedTokenData = loadFixture("src/test/resources/refreshed_mcspv2_token.json", MCSPV2TokenResponse.class); + } + + // This will be our mocked version of the Clock class. + private static MockedStatic clockMock = null; + + @BeforeMethod + public void createEnvMock() { + clockMock = Mockito.mockStatic(Clock.class); + } + + @AfterMethod + public void clearEnvMock() { + if (clockMock != null) { + clockMock.close(); + clockMock = null; + } + } + + // + // Tests involving the Builder class and fromConfiguration() method. + // + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testMissingApiKey() { + new MCSPV2Authenticator.Builder() + .apikey(null) + .url(AUTH_URL) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .build(); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testEmptyApiKey() { + new MCSPV2Authenticator.Builder() + .apikey("") + .url(AUTH_URL) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .build(); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testMissingURL() { + new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(null) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .build(); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testMissingScopeCollectionType() { + new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(AUTH_URL) + .scopeCollectionType(null) + .scopeId(SCOPE_ID) + .build(); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testMissingScopeId() { + new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(AUTH_URL) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(null) + .build(); + } + + @Test + public void testBuilderDefaultConfig() { + // Create an authenticator with only required properties, + // then verify that each optional property contains its default value. + MCSPV2Authenticator authenticator = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(AUTH_URL) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .build(); + assertNotNull(authenticator); + assertEquals(authenticator.authenticationType(), Authenticator.AUTHTYPE_MCSPV2); + assertEquals(authenticator.getApiKey(), API_KEY); + assertEquals(authenticator.getURL(), AUTH_URL); + assertEquals(authenticator.getScopeCollectionType(), SCOPE_COLLECTION_TYPE); + assertEquals(authenticator.getScopeId(), SCOPE_ID); + assertFalse(authenticator.includeBuiltinActions()); + assertFalse(authenticator.includeCustomActions()); + assertTrue(authenticator.includeRoles()); + assertFalse(authenticator.prefixRoles()); + assertNull(authenticator.getCallerExtClaim()); + assertFalse(authenticator.getDisableSSLVerification()); + assertNull(authenticator.getHeaders()); + assertNull(authenticator.getProxy()); + assertNull(authenticator.getProxyAuthenticator()); + } + + @Test + public void testBuilderCorrectConfig() { + Map expectedHeaders = new HashMap<>(); + expectedHeaders.put("header1", "value1"); + expectedHeaders.put("header2", "value2"); + + Map expectedCallerExtClaim = new HashMap<>(); + expectedCallerExtClaim.put("productID", "my-product-123"); + expectedCallerExtClaim.put("serviceID", "my-serviceid-123"); + + MCSPV2Authenticator authenticator = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(AUTH_URL) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .includeBuiltinActions(true) + .includeCustomActions(true) + .includeRoles(false) + .prefixRoles(true) + .callerExtClaim(expectedCallerExtClaim) + .disableSSLVerification(true) + .headers(expectedHeaders) + .proxy(null) + .proxyAuthenticator(null) + .build(); + assertNotNull(authenticator); + assertEquals(authenticator.authenticationType(), Authenticator.AUTHTYPE_MCSPV2); + assertEquals(authenticator.getApiKey(), API_KEY); + assertEquals(authenticator.getURL(), AUTH_URL); + assertEquals(authenticator.getScopeCollectionType(), SCOPE_COLLECTION_TYPE); + assertEquals(authenticator.getScopeId(), SCOPE_ID); + assertTrue(authenticator.includeBuiltinActions()); + assertTrue(authenticator.includeCustomActions()); + assertFalse(authenticator.includeRoles()); + assertTrue(authenticator.prefixRoles()); + assertEquals(authenticator.getCallerExtClaim(), expectedCallerExtClaim); + assertTrue(authenticator.getDisableSSLVerification()); + assertEquals(authenticator.getHeaders(), expectedHeaders); + assertNull(authenticator.getProxy()); + assertNull(authenticator.getProxyAuthenticator()); + + // Next, create a new builder from the existing authenticator, set the "includeRoles" flag + // and build a new authenticator from the builder, then verify that the new authenticator + // is the same as the prior one except for the includeRoles flag. + authenticator = authenticator.newBuilder() + .includeRoles(true) + .build(); + assertNotNull(authenticator); + assertEquals(authenticator.authenticationType(), Authenticator.AUTHTYPE_MCSPV2); + assertEquals(authenticator.getApiKey(), API_KEY); + assertEquals(authenticator.getURL(), AUTH_URL); + assertEquals(authenticator.getScopeCollectionType(), SCOPE_COLLECTION_TYPE); + assertEquals(authenticator.getScopeId(), SCOPE_ID); + assertTrue(authenticator.includeBuiltinActions()); + assertTrue(authenticator.includeCustomActions()); + assertTrue(authenticator.includeRoles()); + assertTrue(authenticator.prefixRoles()); + assertEquals(authenticator.getCallerExtClaim(), expectedCallerExtClaim); + assertTrue(authenticator.getDisableSSLVerification()); + assertEquals(authenticator.getHeaders(), expectedHeaders); + assertNull(authenticator.getProxy()); + assertNull(authenticator.getProxyAuthenticator()); + } + + @Test + public void testConfigCorrectConfig() { + // Create a "callerExtClaim" map and then serialize it to a string. + Map expectedCallerExtClaim = new HashMap<>(); + expectedCallerExtClaim.put("productID", "my-prod-123"); + Gson gson = GsonSingleton.getGsonWithoutPrettyPrinting(); + String callerExtClaimStr = gson.toJson(expectedCallerExtClaim); + + Map props = new HashMap<>(); + props.put(Authenticator.PROPNAME_APIKEY, API_KEY); + props.put(Authenticator.PROPNAME_URL, AUTH_URL); + props.put(Authenticator.PROPNAME_SCOPE_COLLECTION_TYPE, SCOPE_COLLECTION_TYPE); + props.put(Authenticator.PROPNAME_SCOPE_ID, SCOPE_ID); + props.put(Authenticator.PROPNAME_INCLUDE_BUILTIN_ACTIONS, "true"); + props.put(Authenticator.PROPNAME_INCLUDE_ROLES, "false"); + props.put(Authenticator.PROPNAME_DISABLE_SSL, "true"); + props.put(Authenticator.PROPNAME_CALLER_EXT_CLAIM, callerExtClaimStr); + + MCSPV2Authenticator authenticator = MCSPV2Authenticator.fromConfiguration(props); + assertEquals(authenticator.authenticationType(), Authenticator.AUTHTYPE_MCSPV2); + assertEquals(authenticator.getApiKey(), API_KEY); + assertEquals(authenticator.getURL(), AUTH_URL); + assertEquals(authenticator.getScopeCollectionType(), SCOPE_COLLECTION_TYPE); + assertEquals(authenticator.getScopeId(), SCOPE_ID); + assertTrue(authenticator.includeBuiltinActions()); + assertFalse(authenticator.includeRoles()); + assertTrue(authenticator.getDisableSSLVerification()); + } + + @Test(expectedExceptions = IllegalArgumentException.class) + public void testConfigIncorrectConfig1() { + Map props = new HashMap<>(); + props.put(Authenticator.PROPNAME_APIKEY, API_KEY); + props.put(Authenticator.PROPNAME_URL, AUTH_URL); + props.put(Authenticator.PROPNAME_SCOPE_COLLECTION_TYPE, SCOPE_COLLECTION_TYPE); + props.put(Authenticator.PROPNAME_SCOPE_ID, SCOPE_ID); + props.put(Authenticator.PROPNAME_INCLUDE_BUILTIN_ACTIONS, "not_a_boolean"); + + MCSPV2Authenticator.fromConfiguration(props); + } + + @Test(expectedExceptions = JsonSyntaxException.class) + public void testConfigIncorrectConfig2() { + Map props = new HashMap<>(); + props.put(Authenticator.PROPNAME_APIKEY, API_KEY); + props.put(Authenticator.PROPNAME_URL, AUTH_URL); + props.put(Authenticator.PROPNAME_SCOPE_COLLECTION_TYPE, SCOPE_COLLECTION_TYPE); + props.put(Authenticator.PROPNAME_SCOPE_ID, SCOPE_ID); + props.put(Authenticator.PROPNAME_CALLER_EXT_CLAIM, "{ invalid_json!!! }"); + + MCSPV2Authenticator.fromConfiguration(props); + } + + @Test + public void testDisableSSLVerification() { + MCSPV2Authenticator auth = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(AUTH_URL) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .build(); + assertFalse(auth.getDisableSSLVerification()); + + auth = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(AUTH_URL) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .disableSSLVerification(true) + .build(); + assertTrue(auth.getDisableSSLVerification()); + } + + // + // Tests involving interactions with a mocked token service. + // + + @Test + public void testAuthenticateNewAndStoredToken() throws Throwable { + server.enqueue(jsonResponse(tokenData)); + + // Mock current time to ensure that we're way before the token expiration time. + clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 100); + + MCSPV2Authenticator authenticator = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(url) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .disableSSLVerification(true) + .build(); + + // Create a custom client and set it on the authenticator. + ConnectionSpec spec = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) + .allEnabledCipherSuites() + .build(); + OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .connectionSpecs(Arrays.asList(spec, ConnectionSpec.CLEARTEXT)) + .build(); + authenticator = authenticator.newBuilder().client(client).build(); + assertEquals(authenticator.getClient(), client); + + Request.Builder requestBuilder = new Request.Builder().url("https://test.com"); + + // Authenticator should request new, valid token. + authenticator.authenticate(requestBuilder); + verifyAuthHeader(requestBuilder, "Bearer " + tokenData.getToken()); + + // Authenticator should just return the same token this time since we have a valid one stored. + requestBuilder = new Request.Builder().url("https://test.com"); + authenticator.authenticate(requestBuilder); + verifyAuthHeader(requestBuilder, "Bearer " + tokenData.getToken()); + + // Verify that the authenticator is still using the same client instance that we set before. + assertEquals(authenticator.getClient(), client); + } + + @Test + public void testAuthenticationExpiredToken() { + server.enqueue(jsonResponse(tokenData)); + + // Mock current time to ensure that we've passed the token expiration time. + clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 1800000000); + + MCSPV2Authenticator authenticator = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(url) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .build(); + + Request.Builder requestBuilder = new Request.Builder().url("https://test.com"); + + // This will bootstrap the test by forcing the Authenticator to store the expired token + // set above in the mock server. + authenticator.authenticate(requestBuilder); + + // Authenticator should detect the expiration and request a new access token when we call + // authenticate() again. + server.enqueue(jsonResponse(refreshedTokenData)); + authenticator.authenticate(requestBuilder); + verifyAuthHeader(requestBuilder, "Bearer " + refreshedTokenData.getToken()); + } + + @Test + public void testAuthenticationBackgroundTokenRefresh() throws InterruptedException { + server.enqueue(jsonResponse(tokenData)); + + MCSPV2Authenticator authenticator = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(url) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .build(); + + Request.Builder requestBuilder = new Request.Builder().url("https://test.com"); + + // This will bootstrap the test by forcing the Authenticator to store the token needing to be + // refreshed, which was set above in the mock server. + authenticator.authenticate(requestBuilder); + + // Mock current time to put us in the "refresh window" where the token is not expired but still + // needs to be refreshed. This time is within the refresh window associated with the token in mcspv2_token.json + // (i.e. the token contains iat=1747769783, exp=iat+7200=1747776983). + clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 1747775583); + + // Authenticator should detect the need to refresh and request a new access token IN THE BACKGROUND + // when we call authenticate() again. The immediate response should be the token which was already stored, since + // it's not yet expired. + server.enqueue(jsonResponse(refreshedTokenData).setBodyDelay(2, TimeUnit.SECONDS)); + authenticator.authenticate(requestBuilder); + verifyAuthHeader(requestBuilder, "Bearer " + tokenData.getToken()); + + // Sleep to wait out the background refresh of our access token. + Thread.sleep(3000); + + // Next request should use the refreshed token. + requestBuilder = new Request.Builder().url("https://test.com"); + authenticator.authenticate(requestBuilder); + verifyAuthHeader(requestBuilder, "Bearer " + refreshedTokenData.getToken()); + } + + @Test + public void testUserHeaders() throws Throwable { + server.enqueue(jsonResponse(tokenData)); + + // Mock current time to ensure the token is valid. + clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 100); + + Map expectedHeaders = new HashMap<>(); + expectedHeaders.put("header1", "value1"); + expectedHeaders.put("header2", "value2"); + expectedHeaders.put("Host", "mcsp.cloud.ibm.com:81"); + MCSPV2Authenticator authenticator = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(url) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .headers(expectedHeaders) + .build(); + + Request.Builder requestBuilder = new Request.Builder().url("https://test.com"); + + // Authenticator should request new, valid token. + authenticator.authenticate(requestBuilder); + verifyAuthHeader(requestBuilder, "Bearer " + tokenData.getToken()); + + // Now do some validation on the mock request sent to the token server. + RecordedRequest tokenServerRequest = server.takeRequest(); + assertNotNull(tokenServerRequest); + assertNotNull(tokenServerRequest.getHeaders()); + Headers actualHeaders = tokenServerRequest.getHeaders(); + assertEquals(actualHeaders.get("header1"), "value1"); + assertEquals(actualHeaders.get("header2"), "value2"); + assertEquals(actualHeaders.get("Host"), "mcsp.cloud.ibm.com:81"); + assertTrue(actualHeaders.get(HttpHeaders.USER_AGENT).startsWith("ibm-java-sdk-core/mcspv2-authenticator")); + + // Authenticator should just return the same token this time since we have a valid one stored. + requestBuilder = new Request.Builder().url("https://test.com"); + authenticator.authenticate(requestBuilder); + verifyAuthHeader(requestBuilder, "Bearer " + tokenData.getToken()); + } + + @Test + public void testRequestBody() throws Throwable { + server.enqueue(jsonResponse(tokenData)); + + // Mock current time to ensure the token is valid. + clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 100); + + MCSPV2Authenticator authenticator = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(url) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .build(); + + Request.Builder requestBuilder = new Request.Builder().url("https://test.com"); + + // Authenticator should request new, valid token. + authenticator.authenticate(requestBuilder); + verifyAuthHeader(requestBuilder, "Bearer " + tokenData.getToken()); + + // Now do some validation on the mock request sent to the token server. + RecordedRequest tokenServerRequest = server.takeRequest(); + assertNotNull(tokenServerRequest); + String body = tokenServerRequest.getBody().readUtf8(); + String expectedBody = String.format("{\"apikey\":\"%s\"}", API_KEY); + assertEquals(body, expectedBody); + } + + // @Ignore + @Test(expectedExceptions = ServiceResponseException.class) + public void testApiErrorBadRequest() throws Throwable { + server.enqueue(errorResponse(400)); + + // Mock current time to ensure the token is valid. + clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 100); + + MCSPV2Authenticator authenticator = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(url) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .build(); + + Request.Builder requestBuilder = new Request.Builder().url("https://test.com"); + + // Calling authenticate should result in an exception. + authenticator.authenticate(requestBuilder); + } + + @Test + public void testApiResponseError() throws Throwable { + server.enqueue(jsonResponse("{'}")); + + // Mock current time to ensure the token is valid. + clockMock.when(() -> Clock.getCurrentTimeInSeconds()).thenReturn((long) 100); + + MCSPV2Authenticator authenticator = new MCSPV2Authenticator.Builder() + .apikey(API_KEY) + .url(url) + .scopeCollectionType(SCOPE_COLLECTION_TYPE) + .scopeId(SCOPE_ID) + .build(); + + Request.Builder requestBuilder = new Request.Builder().url("https://test.com"); + + // Calling authenticate should result in an exception. + try { + authenticator.authenticate(requestBuilder); + fail("Expected authenticate() to result in exception!"); + } catch (RuntimeException excp) { + Throwable causedBy = excp.getCause(); + assertNotNull(causedBy); + assertTrue(causedBy instanceof IllegalStateException); + } catch (Throwable t) { + fail("Expected RuntimeException, not " + t.getClass().getSimpleName()); + } + } +} diff --git a/src/test/resources/mcspv2_token.json b/src/test/resources/mcspv2_token.json new file mode 100644 index 00000000..af092625 --- /dev/null +++ b/src/test/resources/mcspv2_token.json @@ -0,0 +1,6 @@ +{ + "token": "eyJraWQiOiItSjVNaWlMR00weUdfMDA0WWJabjZHVURoT1lLaVVyUy1DWmU1emJIOEIwIiwiYWxnIjoiUlMyNTYifQ.eyJlbWFpbCI6InBoaWxfYWRhbXNAdXMuaWJtLmNvbSIsIm5hbWUiOiJwaGlsX2FkYW1zQHVzLmlibS5jb20iLCJkaXNwbGF5TmFtZSI6IlBoaWwgQWRhbXMiLCJmYW1pbHlfbmFtZSI6IkFkYW1zIiwiZ2l2ZW5fbmFtZSI6IlBoaWwiLCJpZHAiOnsicmVhbG1OYW1lIjoiUHJlUHJvZC1JQk0taWQiLCJpc3MiOiJodHRwczovL2NvbnNvbGUtaWJtLXN0Zy52ZXJpZnkuaWJtLmNvbS9vYXV0aDIifSwiaWRwVW5pcXVlSWQiOiI2NDMwMDVDUjQ3IiwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50LWlhbS5wbGF0Zm9ybS50ZXN0LnNhYXMuaWJtLmNvbS9hY2NvdW50LWlhbS9hcGkvMi4wIiwiZXhwIjoxNzQ3Nzc2OTgzLCJqdGkiOiJSTF9VYTdzUTQxbXpHMlhxQTVRc1dnIiwiaWF0IjoxNzQ3NzY5NzgzLCJuYmYiOjE3NDc3Njk3NTMsImF1ZCI6IkFDQ09VTlQvMjAyNTA1MTktMjEyOC0zNzU1LTYwYjMtMTAzZTAxYzUwOWU4IiwiZW50aXR5VHlwZSI6IlVTRVIiLCJzdWIiOiJlZWRjYjA2Zi1iNDc0LTQ3YjAtOGU0NS0zMTdiMzc4N2Y3ZWQiLCJhY3Rpb25zIjpbIk1jc3AuQXBpS2V5LkNyZWF0ZSIsIk1jc3AuQXBpS2V5LkRlbGV0ZSIsIk1jc3AuQXBpS2V5LlVwZGF0ZSIsIk1jc3AuQXBpS2V5LlZpZXciLCJNY3NwLkFzc2lzdGFudEtleS5DcmVhdGUiLCJNY3NwLkFzc2lzdGFudEtleS5EZWxldGUiLCJNY3NwLkFzc2lzdGFudEtleS5VcGRhdGUiLCJNY3NwLkFzc2lzdGFudEtleS5WaWV3IiwiTWNzcC5Hcm91cC5DcmVhdGUiLCJNY3NwLkdyb3VwLkRlbGV0ZSIsIk1jc3AuR3JvdXAuVXBkYXRlIiwiTWNzcC5Hcm91cC5WaWV3IiwiTWNzcC5JbnZpdGVkVXNlci5DcmVhdGUiLCJNY3NwLkludml0ZWRVc2VyLkRlbGV0ZSIsIk1jc3AuSW52aXRlZFVzZXIuVmlldyIsIk1jc3AuUm9sZS5DcmVhdGUiLCJNY3NwLlJvbGUuRGVsZXRlIiwiTWNzcC5Sb2xlLlVwZGF0ZSIsIk1jc3AuUm9sZS5WaWV3IiwiTWNzcC5Sb2xlQmluZGluZy5DcmVhdGUiLCJNY3NwLlJvbGVCaW5kaW5nLkRlbGV0ZSIsIk1jc3AuUm9sZUJpbmRpbmcuVmlldyIsIk1jc3AuU2NvcGUuVXBkYXRlIiwiTWNzcC5TY29wZS5WaWV3IiwiTWNzcC5TZXJ2aWNlSWQuQ3JlYXRlIiwiTWNzcC5TZXJ2aWNlSWQuRGVsZXRlIiwiTWNzcC5TZXJ2aWNlSWQuVXBkYXRlIiwiTWNzcC5TZXJ2aWNlSWQuVmlldyIsIk1jc3AuVXNlci5DcmVhdGUiLCJNY3NwLlVzZXIuRGVsZXRlIiwiTWNzcC5Vc2VyLlVwZGF0ZSIsIk1jc3AuVXNlci5WaWV3Il0sImFjY291bnRJZCI6IjIwMjUwNTE5LTIxMjgtMzc1NS02MGIzLTEwM2UwMWM1MDllOCIsInVzZWRCeSI6bnVsbCwiYXBpa2V5VWlkIjoiM2RjNTRlN2QtZjIxNi00YmE5LTlkNjUtNjJhZGUyZWI1MjBkIn0.BrNQiMnr8ZAwZHMiD8G8RWlAgF3Rr9h-TBPocRNUMGNB1fKDWcHLxXFV6MxYkS2760lqc3uf6j8_0FMvJ09J_xx1QlPCtrVvw84g7BqWnBsdNvy3S3pPOhjF5WUgZvWlmCBQR1IjhrVgVp8b997gJtBPer9al8-Pe2_a-5SIOyIMW5h85ZBlFNVrtyjOm_CtFT_Mft8eTxJm-l56spD_EwsIlI29nPI_XBw-xrN8bW6eUVdbEOZRFvpC28NgjFabrY3g8te1sF-S-j0TKprgVny6m59Vb7-6aMb8w6mtKZdUDRJgSFavCqjxOb6DKHAVASSGrW1YUk_6CoPfYZDFgw", + "token_type": "Bearer", + "expires_in": 7200, + "expiration": 1745150400 +} \ No newline at end of file diff --git a/src/test/resources/refreshed_mcspv2_token.json b/src/test/resources/refreshed_mcspv2_token.json new file mode 100644 index 00000000..2f414237 --- /dev/null +++ b/src/test/resources/refreshed_mcspv2_token.json @@ -0,0 +1,6 @@ +{ + "token": "eyJraWQiOiItSjVNaWlMR00weUdfMDA0WWJabjZHVURoT1lLaVVyUy1DWmU1emJIOEIwIiwiYWxnIjoiUlMyNTYifQ.eyJlbWFpbCI6InBoaWxfYWRhbXNAdXMuaWJtLmNvbSIsIm5hbWUiOiJwaGlsX2FkYW1zQHVzLmlibS5jb20iLCJkaXNwbGF5TmFtZSI6IlBoaWwgQWRhbXMiLCJmYW1pbHlfbmFtZSI6IkFkYW1zIiwiZ2l2ZW5fbmFtZSI6IlBoaWwiLCJpZHAiOnsicmVhbG1OYW1lIjoiUHJlUHJvZC1JQk0taWQiLCJpc3MiOiJodHRwczovL2NvbnNvbGUtaWJtLXN0Zy52ZXJpZnkuaWJtLmNvbS9vYXV0aDIifSwiaWRwVW5pcXVlSWQiOiI2NDMwMDVDUjQ3IiwiaXNzIjoiaHR0cHM6Ly9hY2NvdW50LWlhbS5wbGF0Zm9ybS50ZXN0LnNhYXMuaWJtLmNvbS9hY2NvdW50LWlhbS9hcGkvMi4wIiwiZXhwIjoxNzQ3Nzc4NzUwLCJqdGkiOiJRelNpbTEzcUJNSU9oREhMbjRqOHpnIiwiaWF0IjoxNzQ3NzcxNTUwLCJuYmYiOjE3NDc3NzE1MjAsImF1ZCI6IkFDQ09VTlQvMjAyNTA1MTktMjEyOC0zNzU1LTYwYjMtMTAzZTAxYzUwOWU4IiwiZW50aXR5VHlwZSI6IlVTRVIiLCJzdWIiOiJlZWRjYjA2Zi1iNDc0LTQ3YjAtOGU0NS0zMTdiMzc4N2Y3ZWQiLCJhY3Rpb25zIjpbIk1jc3AuQXBpS2V5LkNyZWF0ZSIsIk1jc3AuQXBpS2V5LkRlbGV0ZSIsIk1jc3AuQXBpS2V5LlVwZGF0ZSIsIk1jc3AuQXBpS2V5LlZpZXciLCJNY3NwLkFzc2lzdGFudEtleS5DcmVhdGUiLCJNY3NwLkFzc2lzdGFudEtleS5EZWxldGUiLCJNY3NwLkFzc2lzdGFudEtleS5VcGRhdGUiLCJNY3NwLkFzc2lzdGFudEtleS5WaWV3IiwiTWNzcC5Hcm91cC5DcmVhdGUiLCJNY3NwLkdyb3VwLkRlbGV0ZSIsIk1jc3AuR3JvdXAuVXBkYXRlIiwiTWNzcC5Hcm91cC5WaWV3IiwiTWNzcC5JbnZpdGVkVXNlci5DcmVhdGUiLCJNY3NwLkludml0ZWRVc2VyLkRlbGV0ZSIsIk1jc3AuSW52aXRlZFVzZXIuVmlldyIsIk1jc3AuUm9sZS5DcmVhdGUiLCJNY3NwLlJvbGUuRGVsZXRlIiwiTWNzcC5Sb2xlLlVwZGF0ZSIsIk1jc3AuUm9sZS5WaWV3IiwiTWNzcC5Sb2xlQmluZGluZy5DcmVhdGUiLCJNY3NwLlJvbGVCaW5kaW5nLkRlbGV0ZSIsIk1jc3AuUm9sZUJpbmRpbmcuVmlldyIsIk1jc3AuU2NvcGUuVXBkYXRlIiwiTWNzcC5TY29wZS5WaWV3IiwiTWNzcC5TZXJ2aWNlSWQuQ3JlYXRlIiwiTWNzcC5TZXJ2aWNlSWQuRGVsZXRlIiwiTWNzcC5TZXJ2aWNlSWQuVXBkYXRlIiwiTWNzcC5TZXJ2aWNlSWQuVmlldyIsIk1jc3AuVXNlci5DcmVhdGUiLCJNY3NwLlVzZXIuRGVsZXRlIiwiTWNzcC5Vc2VyLlVwZGF0ZSIsIk1jc3AuVXNlci5WaWV3Il0sImFjY291bnRJZCI6IjIwMjUwNTE5LTIxMjgtMzc1NS02MGIzLTEwM2UwMWM1MDllOCIsInVzZWRCeSI6bnVsbCwiYXBpa2V5VWlkIjoiM2RjNTRlN2QtZjIxNi00YmE5LTlkNjUtNjJhZGUyZWI1MjBkIn0.ldrCDrhAvMkB9NpETo3Tgx_TJTsru08TkJ_Kfr7nOkB11uswO9FPe3wA1WVeiCd2ii3L601WsABzaJc4LO96HX05wZE0VPyilz0oBZ-3iI_jcqSFjXITTyHZj-QzJfp0RGiaFf1ME0C6tdWmJ-z9EOMNci5x8NT0rgadZee4UO7YGa3ClUaIL4hLE8yDhW0dIKQsLT1rOGQdy6sCJZgiQKQMHSdIEy2oXqYx3KKemO0p1B5cTO1bVnH-j953Tp_dc65RmeFldN-wV7mDibh6ed2HsSnpCsaeqcGQDts6hSl1hJ586ytL36FB_0gJv708yCXvOrUHHpxJbdbECd8u1w", + "token_type": "Bearer", + "expires_in": 7200, + "expiration": 1745158000 +} \ No newline at end of file