diff --git a/changelog.md b/changelog.md
index 3212b721..222ab339 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,12 @@
# Changelog
+## v1.8.0
+
+### Sep 15, 2025
+
+- Feature : OAuth 2.0 support with PKCE flow
+- Improved code organization and removed redundant methods
+
## v1.7.1
### Jul 21, 2025
diff --git a/pom.xml b/pom.xml
index 95857077..f2e0e305 100644
--- a/pom.xml
+++ b/pom.xml
@@ -7,7 +7,7 @@
cms
jar
contentstack-management-java
- 1.7.1
+ 1.8.0
Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an
API-first approach
@@ -363,7 +363,7 @@
org.jacoco
jacoco-maven-plugin
- 0.8.7
+ ${jococo-plugin.version}
prepare-agent
diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java
index d5cd049a..dbf3dbc0 100644
--- a/src/main/java/com/contentstack/cms/Contentstack.java
+++ b/src/main/java/com/contentstack/cms/Contentstack.java
@@ -1,33 +1,41 @@
package com.contentstack.cms;
+import java.io.IOException;
+import java.net.Proxy;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+import org.jetbrains.annotations.NotNull;
+
import com.contentstack.cms.core.AuthInterceptor;
import com.contentstack.cms.core.Util;
+import static com.contentstack.cms.core.Util.API_KEY;
+import static com.contentstack.cms.core.Util.AUTHORIZATION;
+import static com.contentstack.cms.core.Util.BRANCH;
import com.contentstack.cms.models.Error;
import com.contentstack.cms.models.LoginDetails;
+import com.contentstack.cms.models.OAuthConfig;
+import com.contentstack.cms.models.OAuthTokens;
+import com.contentstack.cms.oauth.TokenCallback;
+import com.contentstack.cms.oauth.OAuthHandler;
+import com.contentstack.cms.oauth.OAuthInterceptor;
import com.contentstack.cms.organization.Organization;
import com.contentstack.cms.stack.Stack;
import com.contentstack.cms.user.User;
import com.google.gson.Gson;
+
import okhttp3.ConnectionPool;
import okhttp3.OkHttpClient;
import okhttp3.ResponseBody;
import okhttp3.logging.HttpLoggingInterceptor;
-import org.jetbrains.annotations.NotNull;
import retrofit2.Response;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;
-import java.io.IOException;
-import java.net.Proxy;
-import java.time.Duration;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.Objects;
-import java.util.concurrent.TimeUnit;
-import java.util.logging.Logger;
-
-import static com.contentstack.cms.core.Util.*;
-
/**
* Contentstack Java Management SDK
*
@@ -41,7 +49,7 @@
*/
public class Contentstack {
- public final Logger logger = Logger.getLogger(Contentstack.class.getName());
+ public static final Logger logger = Logger.getLogger(Contentstack.class.getName());
protected final String host;
protected final String port;
protected final String version;
@@ -51,6 +59,8 @@ public class Contentstack {
protected final Boolean retryOnFailure;
protected final Proxy proxy;
protected AuthInterceptor interceptor;
+ protected OAuthInterceptor oauthInterceptor;
+ protected OAuthHandler oauthHandler;
protected String[] earlyAccess;
protected User user;
@@ -86,8 +96,9 @@ public class Contentstack {
* @since 2022-05-19
*/
public User user() {
- if (this.authtoken == null)
- throw new NullPointerException(Util.LOGIN_FLAG);
+ if (!isOAuthConfigured() && this.authtoken == null) {
+ throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " user");
+ }
user = new User(this.instance);
return user;
}
@@ -201,15 +212,20 @@ public Response login(String emailId, String password, String tfaT
private void setupLoginCredentials(Response loginResponse) throws IOException {
if (loginResponse.isSuccessful()) {
- assert loginResponse.body() != null;
- // logger.info(loginResponse.body().getNotice());
- this.authtoken = loginResponse.body().getUser().getAuthtoken();
- this.interceptor.setAuthtoken(this.authtoken);
+ LoginDetails loginDetails = loginResponse.body();
+ if (loginDetails != null && loginDetails.getUser() != null) {
+ this.authtoken = loginDetails.getUser().getAuthtoken();
+ if (this.interceptor != null) {
+ this.interceptor.setAuthtoken(this.authtoken);
+ }
+ }
} else {
- assert loginResponse.errorBody() != null;
- String errorJsonString = loginResponse.errorBody().string();
- logger.info(errorJsonString);
- new Gson().fromJson(errorJsonString, Error.class);
+ ResponseBody errorBody = loginResponse.errorBody();
+ if (errorBody != null) {
+ String errorJsonString = errorBody.string();
+ logger.info(errorJsonString);
+ new Gson().fromJson(errorJsonString, Error.class);
+ }
}
}
@@ -274,7 +290,18 @@ Response logoutWithAuthtoken(String authtoken) throws IOException
* @return the organization
*/
public Organization organization() {
- Objects.requireNonNull(this.authtoken, "Please Login to access user instance");
+ if (!isOAuthConfigured() && this.authtoken == null) {
+ throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " organization");
+ }
+
+ // If using OAuth, get organization from tokens
+ if (isOAuthConfigured() && oauthHandler.getTokens() != null) {
+ String orgUid = oauthHandler.getTokens().getOrganizationUid();
+ if (orgUid != null && !orgUid.isEmpty()) {
+ return organization(orgUid);
+ }
+ }
+
return new Organization(this.instance);
}
@@ -298,9 +325,12 @@ public Organization organization() {
*
*/
public Organization organization(@NotNull String organizationUid) {
- Objects.requireNonNull(this.authtoken, "Please Login to access user instance");
- if (organizationUid.isEmpty())
- throw new IllegalStateException("organizationUid can not be empty");
+ if (!isOAuthConfigured() && this.authtoken == null) {
+ throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " organization");
+ }
+ if (organizationUid.isEmpty()) {
+ throw new IllegalStateException(Util.OAUTH_ORG_EMPTY);
+ }
return new Organization(this.instance, organizationUid);
}
@@ -322,7 +352,9 @@ public Organization organization(@NotNull String organizationUid) {
* @return the stack instance
*/
public Stack stack() {
- Objects.requireNonNull(this.authtoken, ILLEGAL_USER);
+ if (!isOAuthConfigured() && this.authtoken == null) {
+ throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " stack");
+ }
return new Stack(this.instance);
}
@@ -345,8 +377,9 @@ public Stack stack() {
* @return the stack instance
*/
public Stack stack(@NotNull Map header) {
- if (this.authtoken == null && !header.containsKey(AUTHORIZATION) && header.get(AUTHORIZATION) == null)
- throw new IllegalStateException(PLEASE_LOGIN);
+ if (!isOAuthConfigured() && this.authtoken == null && !header.containsKey(AUTHORIZATION)) {
+ throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " stack");
+ }
return new Stack(this.instance, header);
}
@@ -435,10 +468,94 @@ public Stack stack(@NotNull String apiKey, @NotNull String managementToken, @Not
}
/**
- * Instantiates a new Contentstack.
- *
- * @param builder the builder
+ * Get the OAuth authorization URL for the user to visit
+ * @return Authorization URL string
+ * @throws IllegalStateException if OAuth is not configured
+ */
+ public String getOAuthAuthorizationUrl() {
+ if (!isOAuthConfigured()) {
+ throw new IllegalStateException(Util.OAUTH_CONFIG_MISSING);
+ }
+ return oauthHandler.authorize();
+ }
+
+ /**
+ * Exchange OAuth authorization code for tokens
+ * @param code Authorization code from OAuth callback
+ * @return CompletableFuture containing OAuth tokens
+ * @throws IllegalStateException if OAuth is not configured
+ */
+ public CompletableFuture exchangeOAuthCode(String code) {
+ if (!isOAuthConfigured()) {
+ throw new IllegalStateException(Util.OAUTH_CONFIG_MISSING);
+ }
+ return oauthHandler.exchangeCodeForToken(code);
+ }
+
+ /**
+ * Refresh the OAuth access token
+ * @return CompletableFuture containing new OAuth tokens
+ * @throws IllegalStateException if OAuth is not configured or no refresh token available
+ */
+ public CompletableFuture refreshOAuthToken() {
+ if (!isOAuthConfigured()) {
+ throw new IllegalStateException(Util.OAUTH_CONFIG_MISSING);
+ }
+ return oauthHandler.refreshAccessToken();
+ }
+
+ /**
+ * Get the current OAuth tokens
+ * @return Current OAuth tokens or null if not available
+ */
+ public OAuthTokens getOAuthTokens() {
+ return oauthHandler != null ? oauthHandler.getTokens() : null;
+ }
+
+ /**
+ * Check if we have valid OAuth tokens
+ * @return true if we have valid tokens
+ */
+ public boolean hasValidOAuthTokens() {
+ return oauthInterceptor != null && oauthInterceptor.hasValidTokens();
+ }
+
+ /**
+ * Check if OAuth is configured
+ * @return true if OAuth is configured
+ */
+ public boolean isOAuthConfigured() {
+ return oauthInterceptor != null && oauthInterceptor.isOAuthConfigured();
+ }
+
+ /**
+ * Get the OAuth handler instance
+ * @return OAuth handler or null if not configured
*/
+ public OAuthHandler getOAuthHandler() {
+ return oauthHandler;
+ }
+
+ /**
+ * Logout from OAuth session and optionally revoke authorization
+ * @param revokeAuthorization If true, revokes the OAuth authorization
+ * @return CompletableFuture that completes when logout is done
+ */
+ public CompletableFuture oauthLogout(boolean revokeAuthorization) {
+ if (!isOAuthConfigured()) {
+ throw new IllegalStateException(Util.OAUTH_CONFIG_MISSING);
+ }
+ return oauthHandler.logout(revokeAuthorization);
+ }
+
+ /**
+ * Logout from OAuth session without revoking authorization
+ * @return CompletableFuture that completes when logout is done
+ */
+ public CompletableFuture oauthLogout() {
+ return oauthLogout(false);
+ }
+
public Contentstack(Builder builder) {
this.host = builder.hostname;
this.port = builder.port;
@@ -449,6 +566,8 @@ public Contentstack(Builder builder) {
this.retryOnFailure = builder.retry;
this.proxy = builder.proxy;
this.interceptor = builder.authInterceptor;
+ this.oauthInterceptor = builder.oauthInterceptor;
+ this.oauthHandler = builder.oauthHandler;
this.earlyAccess = builder.earlyAccess;
}
@@ -462,6 +581,9 @@ public static class Builder {
*/
protected Proxy proxy;
private AuthInterceptor authInterceptor;
+ private OAuthInterceptor oauthInterceptor;
+ private OAuthConfig oauthConfig;
+ private OAuthHandler oauthHandler;
private String authtoken; // authtoken for client
private String[] earlyAccess;
@@ -608,6 +730,81 @@ public Builder setAuthtoken(String authtoken) {
return this;
}
+ /**
+ * Sets OAuth configuration for the client
+ * @param config OAuth configuration
+ * @return Builder instance
+ */
+ public Builder setOAuthConfig(OAuthConfig config) {
+ this.oauthConfig = config;
+ return this;
+ }
+
+ private TokenCallback tokenCallback;
+
+ /**
+ * Sets the token callback for OAuth storage
+ * @param callback The callback to handle token storage
+ * @return Builder instance
+ */
+ public Builder setTokenCallback(TokenCallback callback) {
+ this.tokenCallback = callback;
+ return this;
+ }
+
+ /**
+ * Configures OAuth authentication with PKCE flow (no client secret)
+ * @param appId Application ID
+ * @param clientId Client ID
+ * @param redirectUri Redirect URI
+ * @return Builder instance
+ */
+ public Builder setOAuth(String appId, String clientId, String redirectUri) {
+ // Use the builder's hostname (which defaults to Util.HOST if not set)
+ return setOAuth(appId, clientId, redirectUri, this.hostname);
+ }
+
+ /**
+ * Configures OAuth authentication with PKCE flow (no client secret) and specific host
+ * @param appId Application ID
+ * @param clientId Client ID
+ * @param redirectUri Redirect URI
+ * @param host API host (e.g. "api.contentstack.io", "eu-api.contentstack.com")
+ * @return Builder instance
+ */
+ public Builder setOAuth(String appId, String clientId, String redirectUri, String host) {
+ return setOAuth(appId, clientId, redirectUri, host, null);
+ }
+
+ /**
+ * Configures OAuth authentication with optional client secret. PKCE flow is used when clientSecret is not provided.
+ * @param appId Application ID
+ * @param clientId Client ID
+ * @param redirectUri Redirect URI
+ * @param host API host (e.g. "api.contentstack.io", "eu-api.contentstack.com")
+ * @param clientSecret Optional client secret. If not provided, PKCE flow will be used
+ * @return Builder instance
+ */
+ public Builder setOAuth(String appId, String clientId, String redirectUri, String host, String clientSecret) {
+ OAuthConfig.OAuthConfigBuilder builder = OAuthConfig.builder()
+ .appId(appId)
+ .clientId(clientId)
+ .redirectUri(redirectUri)
+ .host(host);
+
+ // Only set clientSecret if provided (otherwise PKCE flow will be used)
+ if (clientSecret != null && !clientSecret.trim().isEmpty()) {
+ builder.clientSecret(clientSecret);
+ }
+
+ // Add token callback if set
+ if (this.tokenCallback != null) {
+ builder.tokenCallback(this.tokenCallback);
+ }
+
+ this.oauthConfig = builder.build();
+ return this;
+ }
public Builder earlyAccess(String[] earlyAccess) {
this.earlyAccess = earlyAccess;
@@ -631,18 +828,43 @@ private void validateClient(Contentstack contentstack) {
.addConverterFactory(GsonConverterFactory.create())
.client(httpClient(contentstack, this.retry)).build();
contentstack.instance = this.instance;
+
+ // Initialize OAuth if configured
+ if (this.oauthConfig != null) {
+ // OAuth handler and interceptor are created in httpClient
+ contentstack.oauthHandler = this.oauthHandler;
+ contentstack.oauthInterceptor = this.oauthInterceptor;
+ }
}
private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailure) {
- this.authInterceptor = contentstack.interceptor = new AuthInterceptor();
- return new OkHttpClient.Builder()
+ OkHttpClient.Builder builder = new OkHttpClient.Builder()
.connectionPool(this.connectionPool)
- .addInterceptor(this.authInterceptor)
.addInterceptor(logger())
.proxy(this.proxy)
.connectTimeout(Duration.ofSeconds(this.timeout))
- .retryOnConnectionFailure(retryOnFailure)
- .build();
+ .retryOnConnectionFailure(retryOnFailure);
+
+ // Add either OAuth or traditional auth interceptor
+ if (this.oauthConfig != null) {
+ // Create OAuth handler and interceptor first
+ OkHttpClient tempClient = builder.build();
+ this.oauthHandler = new OAuthHandler(tempClient, this.oauthConfig);
+ this.oauthInterceptor = new OAuthInterceptor(this.oauthHandler);
+
+ // Configure early access if needed
+ if (this.earlyAccess != null) {
+ this.oauthInterceptor.setEarlyAccess(this.earlyAccess);
+ }
+
+ // Add interceptor to handle OAuth, token refresh, and retries
+ builder.addInterceptor(this.oauthInterceptor);
+ } else {
+ this.authInterceptor = contentstack.interceptor = new AuthInterceptor();
+ builder.addInterceptor(this.authInterceptor);
+ }
+
+ return builder.build();
}
private HttpLoggingInterceptor logger() {
diff --git a/src/main/java/com/contentstack/cms/core/Util.java b/src/main/java/com/contentstack/cms/core/Util.java
index 7049f198..a07b302e 100644
--- a/src/main/java/com/contentstack/cms/core/Util.java
+++ b/src/main/java/com/contentstack/cms/core/Util.java
@@ -51,6 +51,23 @@ public class Util {
public static final String ERROR_INSTALLATION = "installation uid is required";
public static final String MISSING_ORG_ID = "organization uid is required";
+ // OAuth Constants
+ public static final String OAUTH_APP_HOST = "app.contentstack.com";
+ public static final String OAUTH_API_HOST = "developerhub-api.contentstack.com";
+ public static final String OAUTH_TOKEN_ENDPOINT = "/token";
+ public static final String OAUTH_AUTHORIZE_ENDPOINT = "/#!/apps/%s/authorize";
+
+ // OAuth Error Messages
+ public static final String OAUTH_NO_TOKENS = "No OAuth tokens available. Please authenticate first.";
+ public static final String OAUTH_NO_REFRESH_TOKEN = "No refresh token available";
+ public static final String OAUTH_EMPTY_CODE = "Authorization code cannot be null or empty";
+ public static final String OAUTH_CONFIG_MISSING = "OAuth is not configured. Use Builder.setOAuth() with or without clientSecret for PKCE flow";
+ public static final String OAUTH_REFRESH_FAILED = "Failed to refresh access token";
+ public static final String OAUTH_REVOKE_FAILED = "Failed to revoke authorization";
+ public static final String OAUTH_STATUS_FAILED = "Failed to get authorization status";
+ public static final String OAUTH_LOGIN_REQUIRED = "Please login or configure OAuth to access";
+ public static final String OAUTH_ORG_EMPTY = "organizationUid can not be empty";
+
// The code `Util() throws IllegalAccessException` is a constructor for the
// `Util` class that throws an
// `IllegalAccessException` when called. This constructor is marked as private,
@@ -73,7 +90,7 @@ public class Util {
* string value "Java" concatenated
* with the Java version and the operating system name.
*/
- protected static String defaultUserAgent() {
+ public static String defaultUserAgent() {
String agent = System.getProperty("http.agent");
String operatingSystem = System.getProperty("os.name").toUpperCase();
return agent != null ? agent : ("Java" + System.getProperty("java.version") + " OS: " + operatingSystem);
diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java
new file mode 100644
index 00000000..0b29f02a
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java
@@ -0,0 +1,184 @@
+package com.contentstack.cms.models;
+
+import lombok.Builder;
+import lombok.Getter;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.List;
+
+import com.contentstack.cms.core.Util;
+import com.contentstack.cms.oauth.TokenCallback;
+
+/**
+ * Configuration class for OAuth 2.0 authentication
+ */
+@Getter
+@Builder
+public class OAuthConfig {
+
+ private final String appId;
+ private final String clientId;
+ private final String clientSecret;
+ private final String redirectUri;
+ private final String responseType;
+ private final String scope;
+ private final String authEndpoint;
+ private final String tokenEndpoint;
+ private final String host;
+
+ /**
+ * Callback for token events
+ */
+ private final TokenCallback tokenCallback;
+
+ /**
+ * Validates the configuration
+ *
+ * @throws IllegalArgumentException if required fields are missing or
+ * invalid
+ */
+ public void validate() {
+ if (appId == null || appId.trim().isEmpty()) {
+ throw new IllegalArgumentException("appId is required");
+ }
+ if (clientId == null || clientId.trim().isEmpty()) {
+ throw new IllegalArgumentException("clientId is required");
+ }
+ if (redirectUri == null || redirectUri.trim().isEmpty()) {
+ throw new IllegalArgumentException("redirectUri is required");
+ }
+
+ try {
+ new URL(redirectUri);
+ } catch (MalformedURLException e) {
+ throw new IllegalArgumentException("redirectUri must be a valid URL", e);
+ }
+ }
+
+ /**
+ * Checks if PKCE flow should be used (when clientSecret is not provided)
+ *
+ * @return true if PKCE should be used
+ */
+ public boolean isPkceEnabled() {
+ return clientSecret == null || clientSecret.trim().isEmpty();
+ }
+
+ /**
+ * Gets the formatted authorization endpoint URL
+ *
+ * @return The authorization endpoint URL
+ */
+ public String getFormattedAuthorizationEndpoint() {
+ if (authEndpoint != null) {
+ return authEndpoint;
+ }
+
+ String hostname = host != null ? host : Util.OAUTH_APP_HOST;
+
+ // Transform hostname if needed
+ if (hostname.contains("contentstack")) {
+ hostname = hostname
+ .replaceAll("-api\\.", "-app.") // eu-api.contentstack.com -> eu-app.contentstack.com
+ .replaceAll("^api\\.", "app.") // api.contentstack.io -> app.contentstack.io
+ .replaceAll("\\.io$", ".com"); // *.io -> *.com
+ } else {
+ hostname = Util.OAUTH_APP_HOST;
+ }
+
+ return "https://" + hostname + String.format(Util.OAUTH_AUTHORIZE_ENDPOINT, appId);
+ }
+
+ /**
+ * Gets the formatted token endpoint URL
+ *
+ * @return The token endpoint URL
+ */
+ public String getTokenEndpoint() {
+ if (tokenEndpoint != null) {
+ return tokenEndpoint;
+ }
+
+ String hostname = host != null ? host : Util.OAUTH_API_HOST;
+
+ // Transform hostname if needed
+ if (hostname.contains("contentstack")) {
+ hostname = hostname
+ .replaceAll("-api\\.", "-developerhub-api.") // eu-api.contentstack.com -> eu-developerhub-api.contentstack.com
+ .replaceAll("^api\\.", "developerhub-api.") // api.contentstack.io -> developerhub-api.contentstack.io
+ .replaceAll("\\.io$", ".com"); // *.io -> *.com
+ } else {
+ hostname = Util.OAUTH_API_HOST;
+ }
+
+ return "https://" + hostname + Util.OAUTH_TOKEN_ENDPOINT;
+ }
+
+ /**
+ * Gets the response type, defaulting to "code"
+ *
+ * @return The response type
+ */
+ public String getResponseType() {
+ return responseType != null ? responseType : "code";
+ }
+
+ /**
+ * Gets the scopes as a list
+ *
+ * @return List of scope strings or empty list if no scopes
+ */
+ public List getScopesList() {
+ if (scope == null || scope.trim().isEmpty()) {
+ return List.of();
+ }
+ return Arrays.asList(scope.split(" "));
+ }
+
+ /**
+ * Gets the scope string
+ *
+ * @return The space-delimited scope string or null
+ */
+ public String getScope() {
+ return scope;
+ }
+
+ /**
+ * Builder class for OAuthConfig
+ */
+ public static class OAuthConfigBuilder {
+
+ /**
+ * Sets scopes from a list
+ *
+ * @param scopes List of scope strings
+ * @return Builder instance
+ */
+ public OAuthConfigBuilder scopes(List scopes) {
+ if (scopes == null || scopes.isEmpty()) {
+ this.scope = null;
+ } else {
+ this.scope = String.join(" ", scopes);
+ }
+ return this;
+ }
+
+ /**
+ * Sets scopes from varargs
+ *
+ * @param scopes Scope strings
+ * @return Builder instance
+ */
+ public OAuthConfigBuilder scopes(String... scopes) {
+ if (scopes == null || scopes.length == 0) {
+ this.scope = null;
+ } else {
+ this.scope = String.join(" ", scopes);
+ }
+ return this;
+ }
+ }
+}
diff --git a/src/main/java/com/contentstack/cms/models/OAuthTokens.java b/src/main/java/com/contentstack/cms/models/OAuthTokens.java
new file mode 100644
index 00000000..3d8e4115
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/models/OAuthTokens.java
@@ -0,0 +1,196 @@
+package com.contentstack.cms.models;
+
+import com.google.gson.annotations.SerializedName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * Model class for OAuth tokens and related data
+ */
+@Getter
+@Setter
+public class OAuthTokens {
+
+ private static final String BEARER_TOKEN_TYPE = "Bearer";
+ @SerializedName("access_token")
+ private String accessToken;
+
+ @SerializedName("refresh_token")
+ private String refreshToken;
+
+ @SerializedName("token_type")
+ private String tokenType;
+
+ @SerializedName("expires_in")
+ private Long expiresIn;
+
+ @SerializedName("scope")
+ private String scope;
+
+ @SerializedName("organization_uid")
+ private String organizationUid;
+
+ @SerializedName("user_uid")
+ private String userUid;
+
+ @SerializedName("stack_api_key")
+ private String stackApiKey;
+
+ private Date issuedAt;
+ private Date expiresAt;
+
+ private static final long EXPIRY_BUFFER_MS = 120000; // 2 minutes buffer
+
+ public OAuthTokens() {
+ this.issuedAt = new Date();
+ }
+
+ /**
+ * Sets the expiration time in seconds and calculates the expiry date
+ *
+ * @param expiresIn Expiration time in seconds
+ */
+ public void setExpiresIn(Long expiresIn) {
+ this.expiresIn = expiresIn;
+ if (expiresIn != null) {
+ setExpiresAt(new Date(System.currentTimeMillis() + (expiresIn * 1000)));
+ }
+ }
+
+ public synchronized void setExpiresAt(Date expiresAt) {
+ this.expiresAt = expiresAt != null ? new Date(expiresAt.getTime()) : null;
+ if (expiresAt != null) {
+ this.expiresIn = (expiresAt.getTime() - System.currentTimeMillis()) / 1000;
+ }
+ }
+
+ public synchronized Date getExpiresAt() {
+ return expiresAt != null ? new Date(expiresAt.getTime()) : null;
+ }
+
+ public synchronized Date getIssuedAt() {
+ return issuedAt != null ? new Date(issuedAt.getTime()) : null;
+ }
+
+ /**
+ * Gets the scopes as a list
+ *
+ * @return List of scope strings or empty list if no scopes
+ */
+ public List getScopesList() {
+ if (scope == null || scope.trim().isEmpty()) {
+ return List.of();
+ }
+ return Arrays.asList(scope.split(" "));
+ }
+
+ /**
+ * Sets scopes from a list
+ *
+ * @param scopes List of scope strings
+ */
+ public void setScopesList(List scopes) {
+ if (scopes == null || scopes.isEmpty()) {
+ this.scope = null;
+ } else {
+ this.scope = String.join(" ", scopes);
+ }
+ }
+
+ /**
+ * Checks if the token has a specific scope
+ *
+ * @param scopeToCheck The scope to check for
+ * @return true if the token has the scope
+ */
+ public boolean hasScope(String scopeToCheck) {
+ return getScopesList().contains(scopeToCheck);
+ }
+
+ /**
+ * Checks if the token is expired, including a buffer time
+ *
+ * @return true if token is expired or will expire soon
+ */
+ public boolean isExpired() {
+ // No expiry time means token is considered expired
+ if (expiresAt == null) {
+ return true;
+ }
+
+ // No access token means token is considered expired
+ if (!hasAccessToken()) {
+ return true;
+ }
+
+ // Check if current time + buffer is past expiry
+ long currentTime = System.currentTimeMillis();
+ long expiryTime = expiresAt.getTime();
+ long timeUntilExpiry = expiryTime - currentTime;
+
+ // Consider expired if within buffer window
+ return timeUntilExpiry <= EXPIRY_BUFFER_MS;
+ }
+
+ /**
+ * Checks if the token is valid (has access token and not expired)
+ *
+ * @return true if token is valid
+ */
+ public synchronized boolean isValid() {
+ return hasAccessToken() && !isExpired()
+ && BEARER_TOKEN_TYPE.equalsIgnoreCase(tokenType);
+ }
+
+ /**
+ * Checks if access token is present
+ *
+ * @return true if access token exists
+ */
+ public boolean hasAccessToken() {
+ return accessToken != null && !accessToken.trim().isEmpty();
+ }
+
+ /**
+ * Checks if refresh token is present
+ *
+ * @return true if refresh token exists
+ */
+ public boolean hasRefreshToken() {
+ return refreshToken != null && !refreshToken.trim().isEmpty();
+ }
+
+ /**
+ * Gets time until token expiration in milliseconds
+ *
+ * @return milliseconds until expiration or 0 if expired/invalid
+ */
+ public long getTimeUntilExpiration() {
+ if (expiresAt == null) {
+ return 0;
+ }
+ long timeLeft = expiresAt.getTime() - System.currentTimeMillis();
+ return Math.max(0, timeLeft);
+ }
+
+
+ @Override
+ public String toString() {
+ return "OAuthTokens{"
+ + "accessToken='" + (accessToken != null ? "[REDACTED]" : "null") + '\''
+ + ", refreshToken='" + (refreshToken != null ? "[REDACTED]" : "null") + '\''
+ + ", tokenType='" + tokenType + '\''
+ + ", expiresIn=" + expiresIn
+ + ", scope='" + scope + '\''
+ + ", organizationUid='" + organizationUid + '\''
+ + ", userUid='" + userUid + '\''
+ + ", stackApiKey='" + stackApiKey + '\''
+ + ", issuedAt=" + issuedAt
+ + ", expiresAt=" + expiresAt
+ + '}';
+ }
+}
diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java
new file mode 100644
index 00000000..ac79272e
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java
@@ -0,0 +1,443 @@
+package com.contentstack.cms.oauth;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Base64;
+import java.util.Date;
+import java.util.concurrent.CompletableFuture;
+
+import com.contentstack.cms.core.Util;
+import com.contentstack.cms.models.OAuthConfig;
+import com.contentstack.cms.models.OAuthTokens;
+import com.google.gson.Gson;
+import com.google.gson.JsonParseException;
+
+import lombok.Getter;
+import lombok.Setter;
+import okhttp3.FormBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+/**
+ * Handles OAuth 2.0 authentication flow for Contentstack Supports both
+ * traditional OAuth flow with client secret and PKCE flow
+ */
+@Getter
+public class OAuthHandler {
+
+ private OkHttpClient httpClient;
+ private final OAuthConfig config;
+ private final Gson gson;
+ private final Object tokenLock = new Object();
+
+ private String codeVerifier;
+ private String codeChallenge;
+ private String state;
+
+ @Getter
+ @Setter
+ private OAuthTokens tokens;
+
+ /**
+ * Creates a new OAuth handler instance
+ *
+ * @param httpClient HTTP client for making requests
+ * @param config OAuth configuration
+ */
+ public OAuthHandler(OkHttpClient httpClient, OAuthConfig config) {
+ this.httpClient = httpClient;
+ this.config = config;
+ this.gson = new Gson();
+
+ // Validate config before proceeding
+ config.validate();
+
+ // Only generate PKCE codeVerifier if clientSecret is not provided
+ if (config.getClientSecret() == null || config.getClientSecret().trim().isEmpty()) {
+ this.codeVerifier = generateCodeVerifier();
+ this.codeChallenge = null;
+ }
+ }
+
+ private Request.Builder _getHeaders() {
+ Request.Builder builder = new Request.Builder()
+ .header("Content-Type", "application/x-www-form-urlencoded");
+
+ // Only add authorization header for non-token endpoints
+ if (tokens != null && tokens.getAccessToken() != null) {
+ builder.header("authorization", "Bearer " + tokens.getAccessToken());
+ }
+ return builder;
+ }
+
+ /**
+ * Generates a cryptographically secure code verifier for PKCE
+ *
+ * @return A random URL-safe string between 43-128 characters
+ */
+ private String generateCodeVerifier() {
+ final int CODE_VERIFIER_LENGTH = 96;
+ final String charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
+ SecureRandom random = new SecureRandom();
+ StringBuilder verifier = new StringBuilder();
+ for (int i = 0; i < CODE_VERIFIER_LENGTH; i++) {
+ verifier.append(charset.charAt(random.nextInt(charset.length())));
+ }
+ return verifier.toString();
+ }
+
+ /**
+ * Generates code challenge from code verifier using SHA-256
+ *
+ * @param verifier The code verifier to hash
+ * @return BASE64URL-encoded SHA256 hash of the verifier
+ */
+ private String generateCodeChallenge(String verifier) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(verifier.getBytes(StandardCharsets.UTF_8));
+
+ String base64String = Base64.getEncoder().encodeToString(hash);
+ return base64String
+ .replace('+', '-')
+ .replace('/', '_')
+ .replaceAll("=+$", "");
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("SHA-256 algorithm not available", e);
+ }
+ }
+
+ /**
+ * Starts the OAuth authorization flow
+ *
+ * @return Authorization URL for the user to visit
+ */
+ public String authorize() {
+ try {
+ String baseUrl = config.getFormattedAuthorizationEndpoint();
+
+ StringBuilder urlBuilder = new StringBuilder(baseUrl);
+ urlBuilder.append("?response_type=").append(config.getResponseType())
+ .append("&client_id=").append(URLEncoder.encode(config.getClientId(), "UTF-8"))
+ .append("&redirect_uri=").append(URLEncoder.encode(config.getRedirectUri(), "UTF-8"))
+ .append("&app_id=").append(URLEncoder.encode(config.getAppId(), "UTF-8"));
+
+ // Add scope if provided
+ if (config.getScope() != null && !config.getScope().trim().isEmpty()) {
+ urlBuilder.append("&scope=").append(URLEncoder.encode(config.getScope(), "UTF-8"));
+ }
+
+ if (config.getClientSecret() != null && !config.getClientSecret().trim().isEmpty()) {
+ return urlBuilder.toString();
+ } else {
+ // PKCE flow: add code_challenge
+ this.codeChallenge = generateCodeChallenge(this.codeVerifier);
+ urlBuilder.append("&code_challenge=").append(URLEncoder.encode(this.codeChallenge, "UTF-8"))
+ .append("&code_challenge_method=S256");
+ return urlBuilder.toString();
+ }
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to encode URL parameters", e);
+ }
+ }
+
+ /**
+ * Exchanges authorization code for tokens
+ *
+ * @param code The authorization code from callback
+ * @return Future containing the tokens
+ */
+ public CompletableFuture exchangeCodeForToken(String code) {
+ if (code == null || code.trim().isEmpty()) {
+ return CompletableFuture.failedFuture(new IllegalArgumentException(Util.OAUTH_EMPTY_CODE));
+ }
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ FormBody.Builder formBuilder = new FormBody.Builder()
+ .add("grant_type", "authorization_code")
+ .add("code", code.trim())
+ .add("redirect_uri", config.getRedirectUri())
+ .add("client_id", config.getClientId())
+ .add("app_id", config.getAppId());
+
+ // Choose between client_secret and code_verifier like JS SDK
+ if (config.getClientSecret() != null) {
+ formBuilder.add("client_secret", config.getClientSecret());
+ } else {
+ formBuilder.add("code_verifier", this.codeVerifier);
+ }
+
+ Request request = _getHeaders()
+ .url(config.getTokenEndpoint())
+ .post(formBuilder.build())
+ .build();
+
+ return executeTokenRequest(request);
+ } catch (IOException | RuntimeException e) {
+ throw new RuntimeException("Failed to exchange code for tokens", e);
+ }
+ });
+ }
+
+ /**
+ * Saves tokens from a successful OAuth response
+ *
+ * @param tokens The tokens to save
+ */
+ private void _saveTokens(OAuthTokens tokens) {
+ synchronized (tokenLock) {
+ this.tokens = tokens;
+ if (config.getTokenCallback() != null) {
+ if (tokens != null) {
+ config.getTokenCallback().onTokensUpdated(tokens);
+ } else {
+ config.getTokenCallback().onTokensCleared();
+ }
+ }
+ }
+ }
+
+ private OAuthTokens _getTokens() {
+ synchronized (tokenLock) {
+ return this.tokens;
+ }
+ }
+
+ /**
+ * Refreshes the access token using the refresh token
+ *
+ * @return Future containing the new tokens
+ */
+ public CompletableFuture refreshAccessToken() {
+ // Check if we have tokens and refresh token
+ if (tokens == null) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException(Util.OAUTH_NO_TOKENS));
+ }
+ if (!tokens.hasRefreshToken()) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException(Util.OAUTH_NO_REFRESH_TOKEN));
+ }
+
+ // Check if token is actually expired
+ if (!tokens.isExpired()) {
+ return CompletableFuture.completedFuture(tokens);
+ }
+
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+
+ FormBody.Builder formBuilder = new FormBody.Builder()
+ .add("grant_type", "refresh_token")
+ .add("refresh_token", tokens.getRefreshToken())
+ .add("client_id", config.getClientId())
+ .add("app_id", config.getAppId());
+
+ // Add client_secret if available, otherwise add code_verifier
+ if (config.getClientSecret() != null && !config.getClientSecret().trim().isEmpty()) {
+ formBuilder.add("client_secret", config.getClientSecret());
+ } else if (this.codeVerifier != null) {
+ formBuilder.add("code_verifier", this.codeVerifier);
+ }
+
+ Request request = new Request.Builder()
+ .url(config.getTokenEndpoint())
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .post(formBuilder.build())
+ .build();
+
+ OAuthTokens newTokens = executeTokenRequest(request);
+
+ return newTokens;
+ } catch (IOException | RuntimeException e) {
+
+ throw new RuntimeException(Util.OAUTH_REFRESH_FAILED, e);
+ }
+ });
+ }
+
+ /**
+ * Executes a token request and processes the response
+ */
+ private OAuthTokens executeTokenRequest(Request request) throws IOException {
+
+ Response response = null;
+ ResponseBody responseBody = null;
+ try {
+ response = httpClient.newCall(request).execute();
+ responseBody = response.body();
+
+ if (!response.isSuccessful()) {
+ String error = responseBody != null ? responseBody.string() : "Unknown error";
+
+ // Try to parse error as JSON for better error message
+ try {
+ com.contentstack.cms.models.Error errorObj = gson.fromJson(error, com.contentstack.cms.models.Error.class);
+ throw new RuntimeException("Token request failed: "
+ + (errorObj != null ? errorObj.getErrorMessage() : error));
+ } catch (JsonParseException e) {
+ // If not JSON, use raw error string
+ throw new RuntimeException("Token request failed with status "
+ + response.code() + ": " + error);
+ }
+ }
+
+ String body = responseBody != null ? responseBody.string() : "{}";
+
+ OAuthTokens newTokens = gson.fromJson(body, OAuthTokens.class);
+
+ // Set token expiry time
+ if (newTokens.getExpiresIn() != null) {
+ newTokens.setExpiresAt(new Date(System.currentTimeMillis() + (newTokens.getExpiresIn() * 1000)));
+ }
+
+ // Keep refresh token if new one not provided
+ if (newTokens.getRefreshToken() == null && this.tokens != null && this.tokens.hasRefreshToken()) {
+ newTokens.setRefreshToken(this.tokens.getRefreshToken());
+ }
+
+ _saveTokens(newTokens);
+ return newTokens;
+ } catch (JsonParseException e) {
+ throw new RuntimeException("Failed to parse token response", e);
+ } finally {
+ if (responseBody != null) {
+ responseBody.close();
+ }
+ if (response != null) {
+ response.close();
+ }
+ }
+ }
+
+ /**
+ * Logs out the user and optionally revokes authorization
+ *
+ * @param revokeAuthorization Whether to revoke the app authorization
+ */
+ public CompletableFuture logout(boolean revokeAuthorization) {
+ return CompletableFuture.runAsync(() -> {
+ if (revokeAuthorization && tokens != null) {
+ revokeOauthAppAuthorization().join();
+ }
+ this.tokens = null;
+ });
+ }
+
+ /**
+ * Gets the current OAuth app authorization status
+ *
+ * @return Future containing the authorization details
+ */
+ public CompletableFuture getOauthAppAuthorization() {
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ Request request = _getHeaders()
+ .url(config.getFormattedAuthorizationEndpoint() + "/status")
+ .get()
+ .build();
+
+ Response response = null;
+ ResponseBody responseBody = null;
+ try {
+ response = httpClient.newCall(request).execute();
+ responseBody = response.body();
+
+ if (!response.isSuccessful()) {
+ String error = responseBody != null ? responseBody.string() : "Unknown error";
+ throw new RuntimeException(Util.OAUTH_STATUS_FAILED + ": " + error);
+ }
+
+ String body = responseBody != null ? responseBody.string() : "{}";
+ return gson.fromJson(body, OAuthTokens.class);
+ } finally {
+ if (responseBody != null) {
+ responseBody.close();
+ }
+ if (response != null) {
+ response.close();
+ }
+ }
+ } catch (IOException | RuntimeException e) {
+ throw new RuntimeException("Failed to get authorization status", e);
+ }
+ });
+ }
+
+ /**
+ * Revokes the OAuth app authorization
+ *
+ * @return Future that completes when revocation is done
+ */
+ public CompletableFuture revokeOauthAppAuthorization() {
+ return CompletableFuture.runAsync(() -> {
+ try {
+ Request request = _getHeaders()
+ .url(config.getFormattedAuthorizationEndpoint() + "/revoke")
+ .post(new FormBody.Builder()
+ .add("app_id", config.getAppId())
+ .add("client_id", config.getClientId())
+ .build())
+ .build();
+
+ Response response = null;
+ ResponseBody responseBody = null;
+ try {
+ response = httpClient.newCall(request).execute();
+ responseBody = response.body();
+
+ if (!response.isSuccessful()) {
+ String error = responseBody != null ? responseBody.string() : "Unknown error";
+ throw new RuntimeException(Util.OAUTH_REVOKE_FAILED + ": " + error);
+ }
+ } finally {
+ if (responseBody != null) {
+ responseBody.close();
+ }
+ if (response != null) {
+ response.close();
+ }
+ }
+ } catch (IOException | RuntimeException e) {
+ throw new RuntimeException("Failed to revoke authorization", e);
+ }
+ });
+ }
+
+ // Convenience methods for token access
+ public String getAccessToken() {
+ OAuthTokens accessToken = _getTokens();
+ return accessToken != null ? accessToken.getAccessToken() : null;
+ }
+
+ public String getRefreshToken() {
+ OAuthTokens refreshToken = _getTokens();
+ return refreshToken != null ? refreshToken.getRefreshToken() : null;
+ }
+
+ public String getOrganizationUID() {
+ OAuthTokens organizationUid = _getTokens();
+ return organizationUid != null ? organizationUid.getOrganizationUid() : null;
+ }
+
+ public String getUserUID() {
+ OAuthTokens userUid = _getTokens();
+ return userUid != null ? userUid.getUserUid() : null;
+ }
+
+ /**
+ * Checks if we have a valid access token
+ *
+ * @return true if we have a non-expired access token
+ */
+ public boolean hasValidAccessToken() {
+ OAuthTokens accessToken = _getTokens();
+ return accessToken != null && accessToken.hasAccessToken() && !accessToken.isExpired();
+ }
+}
diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java
new file mode 100644
index 00000000..baf3997b
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java
@@ -0,0 +1,133 @@
+package com.contentstack.cms.oauth;
+
+import java.io.IOException;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import com.contentstack.cms.core.Util;
+
+import okhttp3.Interceptor;
+import okhttp3.Request;
+import okhttp3.Response;
+
+public class OAuthInterceptor implements Interceptor {
+
+ private static final int MAX_RETRIES = 3;
+ private final OAuthHandler oauthHandler;
+ private String[] earlyAccess;
+ private final Object refreshLock = new Object();
+
+ public OAuthInterceptor(OAuthHandler oauthHandler) {
+ this.oauthHandler = oauthHandler;
+ }
+
+ public void setEarlyAccess(String[] earlyAccess) {
+ this.earlyAccess = earlyAccess;
+ }
+
+ public boolean isOAuthConfigured() {
+ return oauthHandler != null && oauthHandler.getConfig() != null;
+ }
+
+ public boolean hasValidTokens() {
+ return oauthHandler != null
+ && oauthHandler.getTokens() != null
+ && !oauthHandler.getTokens().isExpired();
+ }
+
+ @Override
+ public Response intercept(Chain chain) throws IOException {
+ Request originalRequest = chain.request();
+ Request.Builder requestBuilder = originalRequest.newBuilder()
+ .header("X-User-Agent", Util.defaultUserAgent())
+ .header("User-Agent", Util.defaultUserAgent())
+ .header("Content-Type", originalRequest.url().toString().contains("/token") ? "application/x-www-form-urlencoded" : "application/json")
+ .header("x-header-ea", earlyAccess != null ? String.join(",", earlyAccess) : "true");
+ // Skip auth header for token endpoints
+ if (!originalRequest.url().toString().contains("/token")) {
+ if (oauthHandler.getTokens() != null && oauthHandler.getTokens().hasAccessToken()) {
+ requestBuilder.header("Authorization", "Bearer " + oauthHandler.getAccessToken());
+
+ }
+ }
+
+ // Execute request with retry and refresh handling
+ return executeRequest(chain, requestBuilder.build(), 0);
+ }
+
+ private Response executeRequest(Chain chain, Request request, int retryCount) throws IOException {
+ // Skip token refresh for token endpoints to avoid infinite loops
+ if (request.url().toString().contains("/token")) {
+ return chain.proceed(request);
+ }
+
+ // Ensure we have tokens
+ if (oauthHandler == null || oauthHandler.getTokens() == null) {
+ throw new IOException(Util.OAUTH_NO_TOKENS);
+ }
+
+ // Check if we need to refresh the token before making the request
+ if (oauthHandler.getTokens().isExpired()) {
+
+ synchronized (refreshLock) {
+ try {
+
+ oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS);
+
+ // Update authorization header with new token
+ request = request.newBuilder()
+ .header("Authorization", "Bearer " + oauthHandler.getAccessToken())
+ .build();
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+
+ throw new IOException(Util.OAUTH_REFRESH_FAILED, e);
+ }
+ }
+ }
+
+ // Execute request
+ Response response = chain.proceed(request);
+
+ // Handle error responses
+ if (!response.isSuccessful() && retryCount < MAX_RETRIES) {
+ int code = response.code();
+ response.close();
+
+ // Handle 401 with token refresh
+ if (code == 401 && oauthHandler != null && oauthHandler.getTokens() != null
+ && oauthHandler.getTokens().hasRefreshToken()) {
+
+ synchronized (refreshLock) {
+ try {
+
+ oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS);
+
+ // Update authorization header with new token
+ request = request.newBuilder()
+ .header("Authorization", "Bearer " + oauthHandler.getAccessToken())
+ .build();
+
+ return executeRequest(chain, request, retryCount + 1);
+ } catch (InterruptedException | ExecutionException | TimeoutException e) {
+ throw new IOException(Util.OAUTH_REFRESH_FAILED + " after 401", e);
+ }
+ }
+ }
+
+ // Handle other retryable errors (429, 5xx)
+ if ((code == 429 || code >= 500) && code != 501) {
+ try {
+ long delay = Math.min(1000 * (1 << retryCount), 30000);
+ Thread.sleep(delay);
+ return executeRequest(chain, request, retryCount + 1);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Retry interrupted", e);
+ }
+ }
+ }
+
+ return response;
+ }
+}
diff --git a/src/main/java/com/contentstack/cms/oauth/TokenCallback.java b/src/main/java/com/contentstack/cms/oauth/TokenCallback.java
new file mode 100644
index 00000000..64575070
--- /dev/null
+++ b/src/main/java/com/contentstack/cms/oauth/TokenCallback.java
@@ -0,0 +1,19 @@
+package com.contentstack.cms.oauth;
+
+import com.contentstack.cms.models.OAuthTokens;
+
+/**
+ * Callback interface for token events
+ */
+public interface TokenCallback {
+ /**
+ * Called when tokens are updated
+ * @param tokens The new tokens
+ */
+ void onTokensUpdated(OAuthTokens tokens);
+
+ /**
+ * Called when tokens are cleared
+ */
+ void onTokensCleared();
+}
diff --git a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java
new file mode 100644
index 00000000..3b09c507
--- /dev/null
+++ b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java
@@ -0,0 +1,587 @@
+package com.contentstack.cms.oauth;
+
+import java.io.IOException;
+import java.util.concurrent.*;
+
+import static org.junit.Assert.*;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import static org.mockito.ArgumentMatchers.any;
+import org.mockito.Mock;
+import static org.mockito.Mockito.*;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import com.contentstack.cms.Contentstack;
+import com.contentstack.cms.core.Util;
+import com.contentstack.cms.models.OAuthConfig;
+import com.contentstack.cms.models.OAuthTokens;
+import com.google.gson.Gson;
+
+import okhttp3.*;
+import okio.Buffer;
+
+
+@RunWith(MockitoJUnitRunner.class)
+public class OAuthTest {
+
+ // Using lenient mocks to avoid unnecessary stubbing warnings
+ // when some mock setups are not used in every test
+
+ private static final String TEST_APP_ID = "test_app_id";
+ private static final String TEST_CLIENT_ID = "test_client_id";
+ private static final String TEST_CLIENT_SECRET = "test_client_secret";
+ private static final String TEST_REDIRECT_URI = "https://example.com/callback";
+ private static final String TEST_AUTH_CODE = "test_auth_code";
+ private static final String TEST_ACCESS_TOKEN = "test_access_token";
+ private static final String TEST_REFRESH_TOKEN = "test_refresh_token";
+
+ @Mock(lenient = true)
+ private OkHttpClient mockHttpClient;
+
+ @Mock(lenient = true)
+ private Call mockCall;
+
+ @Mock(lenient = true)
+ private Response mockResponse;
+
+ @Mock(lenient = true)
+ private ResponseBody mockResponseBody;
+
+ private OAuthHandler pkceHandler;
+ private OAuthHandler clientSecretHandler;
+ private Contentstack pkceClient;
+ private Contentstack clientSecretClient;
+ private Gson gson;
+
+ @Before
+ public void setup() {
+ gson = new Gson();
+
+ // Create OAuth configuration for PKCE (no client secret)
+ OAuthConfig pkceConfig = OAuthConfig.builder()
+ .appId(TEST_APP_ID)
+ .clientId(TEST_CLIENT_ID)
+ .redirectUri(TEST_REDIRECT_URI)
+ .build();
+
+ pkceHandler = new OAuthHandler(mockHttpClient, pkceConfig);
+
+ // Create OAuth configuration with client secret
+ OAuthConfig clientSecretConfig = OAuthConfig.builder()
+ .appId(TEST_APP_ID)
+ .clientId(TEST_CLIENT_ID)
+ .clientSecret(TEST_CLIENT_SECRET)
+ .redirectUri(TEST_REDIRECT_URI)
+ .build();
+
+ clientSecretHandler = new OAuthHandler(mockHttpClient, clientSecretConfig);
+
+ // Create Contentstack clients
+ pkceClient = new Contentstack.Builder()
+ .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI)
+ .build();
+
+ clientSecretClient = new Contentstack.Builder()
+ .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI, Util.HOST, TEST_CLIENT_SECRET)
+ .build();
+ }
+
+ // =================
+ // CONFIGURATION TESTS
+ // =================
+
+ @Test
+ public void testPKCEConfiguration() {
+ assertTrue("PKCE OAuth should be configured", pkceClient.isOAuthConfigured());
+ assertNotNull("PKCE OAuth handler should exist", pkceClient.getOAuthHandler());
+ assertFalse("Should not have tokens initially", pkceClient.hasValidOAuthTokens());
+ assertTrue("Should be PKCE enabled", pkceHandler.getConfig().isPkceEnabled());
+ }
+
+ @Test
+ public void testClientSecretConfiguration() {
+ assertTrue("Client Secret OAuth should be configured", clientSecretClient.isOAuthConfigured());
+ assertNotNull("Client Secret OAuth handler should exist", clientSecretClient.getOAuthHandler());
+ assertFalse("Should not have tokens initially", clientSecretClient.hasValidOAuthTokens());
+ assertFalse("Should not be PKCE enabled", clientSecretHandler.getConfig().isPkceEnabled());
+ }
+
+ @Test
+ public void testInvalidConfigurations() {
+ // Test invalid app ID
+ try {
+ new Contentstack.Builder()
+ .setOAuth("", TEST_CLIENT_ID, TEST_REDIRECT_URI)
+ .build();
+ fail("Should throw exception for empty app ID");
+ } catch (IllegalArgumentException e) {
+ assertTrue("Should mention app ID", e.getMessage().contains("appId"));
+ }
+
+ // Test invalid client ID
+ try {
+ new Contentstack.Builder()
+ .setOAuth(TEST_APP_ID, "", TEST_CLIENT_SECRET, TEST_REDIRECT_URI)
+ .build();
+ fail("Should throw exception for empty client ID");
+ } catch (IllegalArgumentException e) {
+ assertTrue("Should mention client ID", e.getMessage().contains("clientId"));
+ }
+
+ // Test invalid redirect URI
+ try {
+ new Contentstack.Builder()
+ .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_CLIENT_SECRET, "invalid-url")
+ .build();
+ fail("Should throw exception for invalid redirect URI");
+ } catch (IllegalArgumentException e) {
+ assertTrue("Should mention redirect URI", e.getMessage().contains("redirectUri"));
+ }
+ }
+
+ // =================
+ // AUTHORIZATION URL TESTS
+ // =================
+
+ @Test
+ public void testPKCEAuthorizationUrlGeneration() {
+ String authUrl = pkceHandler.authorize();
+
+ assertNotNull("Authorization URL should not be null", authUrl);
+ assertTrue("URL should contain app ID", authUrl.contains("app_id=" + TEST_APP_ID));
+ assertTrue("URL should contain client ID", authUrl.contains("client_id=" + TEST_CLIENT_ID));
+ assertTrue("URL should contain redirect URI", authUrl.contains("redirect_uri="));
+ assertTrue("URL should contain PKCE code challenge", authUrl.contains("code_challenge="));
+ assertTrue("URL should contain PKCE method", authUrl.contains("code_challenge_method=S256"));
+ assertTrue("URL should contain response type", authUrl.contains("response_type=code"));
+ assertFalse("URL should not contain client_secret", authUrl.contains("client_secret"));
+ }
+
+ @Test
+ public void testClientSecretAuthorizationUrlGeneration() {
+ String authUrl = clientSecretHandler.authorize();
+
+ assertNotNull("Authorization URL should not be null", authUrl);
+ assertTrue("URL should contain app ID", authUrl.contains("app_id=" + TEST_APP_ID));
+ assertTrue("URL should contain client ID", authUrl.contains("client_id=" + TEST_CLIENT_ID));
+ assertTrue("URL should contain redirect URI", authUrl.contains("redirect_uri="));
+ assertTrue("URL should contain response type", authUrl.contains("response_type=code"));
+ assertFalse("URL should not contain PKCE code challenge", authUrl.contains("code_challenge="));
+ assertFalse("URL should not contain PKCE method", authUrl.contains("code_challenge_method"));
+ }
+
+ @Test
+ public void testAuthUrlUniqueness() {
+ // Create two PKCE handlers and verify they generate different URLs due to different code challenges
+ OAuthConfig config1 = OAuthConfig.builder()
+ .appId(TEST_APP_ID)
+ .clientId(TEST_CLIENT_ID)
+ .redirectUri(TEST_REDIRECT_URI)
+ .build();
+
+ OAuthConfig config2 = OAuthConfig.builder()
+ .appId(TEST_APP_ID)
+ .clientId(TEST_CLIENT_ID)
+ .redirectUri(TEST_REDIRECT_URI)
+ .build();
+
+ OAuthHandler handler1 = new OAuthHandler(mockHttpClient, config1);
+ OAuthHandler handler2 = new OAuthHandler(mockHttpClient, config2);
+
+ String url1 = handler1.authorize();
+ String url2 = handler2.authorize();
+
+ assertNotEquals("URLs should be different due to different PKCE challenges", url1, url2);
+ }
+
+ @Test
+ public void testDefaultOAuthEndpoints() {
+ // Test with default hosts
+ OAuthConfig config = OAuthConfig.builder()
+ .appId(TEST_APP_ID)
+ .clientId(TEST_CLIENT_ID)
+ .redirectUri(TEST_REDIRECT_URI)
+ .build();
+ OAuthHandler handler = new OAuthHandler(mockHttpClient, config);
+
+ String authUrl = handler.authorize();
+ String tokenUrl = config.getTokenEndpoint();
+
+ assertTrue("Auth URL should use default app host",
+ authUrl.contains(Util.OAUTH_APP_HOST));
+ assertTrue("Token URL should use default API host",
+ tokenUrl.contains(Util.OAUTH_API_HOST));
+ }
+
+ @Test
+ public void testHostTransformations() {
+ // Test cases: {API Host, Expected App Host, Expected Token Host}
+ String[][] testCases = {
+ // Default region
+ {"api.contentstack.io", "app.contentstack.com", "developerhub-api.contentstack.com"},
+ {"api-contentstack.com", "app-contentstack.com", "developerhub-api-contentstack.com"},
+
+ // AWS regions
+ {"eu-api.contentstack.com", "eu-app.contentstack.com", "eu-developerhub-api.contentstack.com"},
+ {"eu-api-contentstack.com", "eu-app-contentstack.com", "eu-developerhub-api-contentstack.com"},
+ {"au-api.contentstack.com", "au-app.contentstack.com", "au-developerhub-api.contentstack.com"},
+ {"au-api-contentstack.com", "au-app-contentstack.com", "au-developerhub-api-contentstack.com"},
+
+ // Azure regions
+ {"azure-na-api.contentstack.com", "azure-na-app.contentstack.com", "azure-na-developerhub-api.contentstack.com"},
+ {"azure-na-api-contentstack.com", "azure-na-app-contentstack.com", "azure-na-developerhub-api-contentstack.com"},
+ {"azure-eu-api.contentstack.com", "azure-eu-app.contentstack.com", "azure-eu-developerhub-api.contentstack.com"},
+ {"azure-eu-api-contentstack.com", "azure-eu-app-contentstack.com", "azure-eu-developerhub-api-contentstack.com"},
+
+ // GCP regions
+ {"gcp-na-api.contentstack.com", "gcp-na-app.contentstack.com", "gcp-na-developerhub-api.contentstack.com"},
+ {"gcp-na-api-contentstack.com", "gcp-na-app-contentstack.com", "gcp-na-developerhub-api-contentstack.com"},
+ {"gcp-eu-api.contentstack.com", "gcp-eu-app.contentstack.com", "gcp-eu-developerhub-api.contentstack.com"},
+ {"gcp-eu-api-contentstack.com", "gcp-eu-app-contentstack.com", "gcp-eu-developerhub-api-contentstack.com"}
+ };
+
+ for (String[] testCase : testCases) {
+ String apiHost = testCase[0];
+ String expectedAppHost = testCase[1];
+ String expectedTokenHost = testCase[2];
+
+ OAuthConfig config = OAuthConfig.builder()
+ .appId(TEST_APP_ID)
+ .clientId(TEST_CLIENT_ID)
+ .redirectUri(TEST_REDIRECT_URI)
+ .host(apiHost)
+ .build();
+ OAuthHandler handler = new OAuthHandler(mockHttpClient, config);
+
+ String authUrl = handler.authorize();
+ String tokenUrl = config.getTokenEndpoint();
+
+ assertTrue(String.format("Auth URL for %s should contain %s", apiHost, expectedAppHost),
+ authUrl.contains(expectedAppHost));
+ assertTrue(String.format("Token URL for %s should contain %s", apiHost, expectedTokenHost),
+ tokenUrl.contains(expectedTokenHost));
+ }
+ }
+
+ @Test
+ public void testHostStorage() {
+ String testHost = "eu-api.contentstack.com";
+
+ // Test host storage in OAuthConfig
+ OAuthConfig config = OAuthConfig.builder()
+ .appId(TEST_APP_ID)
+ .clientId(TEST_CLIENT_ID)
+ .redirectUri(TEST_REDIRECT_URI)
+ .host(testHost)
+ .build();
+
+ assertEquals("Host should be stored in OAuthConfig",
+ testHost, config.getHost());
+
+ // Test host storage via Contentstack.Builder
+ Contentstack client = new Contentstack.Builder()
+ .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI, testHost, TEST_CLIENT_SECRET)
+ .build();
+
+ String authUrl = client.getOAuthAuthorizationUrl();
+ assertTrue("Auth URL should use stored host",
+ authUrl.contains("eu-app.contentstack.com"));
+
+ // Test host storage via PKCE builder
+ client = new Contentstack.Builder()
+ .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI, testHost)
+ .build();
+
+ authUrl = client.getOAuthAuthorizationUrl();
+ assertTrue("Auth URL should use stored host with PKCE",
+ authUrl.contains("eu-app.contentstack.com"));
+ }
+
+ @Test
+ public void testDefaultHosts() {
+ // Test with no host specified
+ OAuthConfig config = OAuthConfig.builder()
+ .appId(TEST_APP_ID)
+ .clientId(TEST_CLIENT_ID)
+ .redirectUri(TEST_REDIRECT_URI)
+ .build();
+ OAuthHandler handler = new OAuthHandler(mockHttpClient, config);
+
+ String authUrl = handler.authorize();
+ String tokenUrl = config.getTokenEndpoint();
+
+ assertTrue("Auth URL should use default app host",
+ authUrl.contains(Util.OAUTH_APP_HOST));
+ assertTrue("Token URL should use default API host",
+ tokenUrl.contains(Util.OAUTH_API_HOST));
+ }
+
+ @Test
+ public void testCustomEndpoints() {
+ // Test with custom endpoints
+ String customAuthEndpoint = "https://custom.auth.endpoint";
+ String customTokenEndpoint = "https://custom.token.endpoint";
+
+ OAuthConfig config = OAuthConfig.builder()
+ .appId(TEST_APP_ID)
+ .clientId(TEST_CLIENT_ID)
+ .redirectUri(TEST_REDIRECT_URI)
+ .authEndpoint(customAuthEndpoint)
+ .tokenEndpoint(customTokenEndpoint)
+ .build();
+ OAuthHandler handler = new OAuthHandler(mockHttpClient, config);
+
+ String authUrl = handler.authorize();
+ String tokenUrl = config.getTokenEndpoint();
+
+ assertEquals("Should use custom auth endpoint",
+ customAuthEndpoint, authUrl);
+ assertEquals("Should use custom token endpoint",
+ customTokenEndpoint, tokenUrl);
+ }
+
+
+
+ // =================
+ // TOKEN EXCHANGE TESTS
+ // =================
+
+ @Test
+ public void testSuccessfulPKCETokenExchange() throws IOException, ExecutionException, InterruptedException {
+ setupSuccessfulTokenResponse();
+
+ CompletableFuture future = pkceHandler.exchangeCodeForToken(TEST_AUTH_CODE);
+ OAuthTokens tokens = future.get();
+
+ verifyTokensValid(tokens);
+ verifyPKCETokenRequest();
+ }
+
+ @Test
+ public void testSuccessfulClientSecretTokenExchange() throws IOException, ExecutionException, InterruptedException {
+ setupSuccessfulTokenResponse();
+
+ CompletableFuture future = clientSecretHandler.exchangeCodeForToken(TEST_AUTH_CODE);
+ OAuthTokens tokens = future.get();
+
+ verifyTokensValid(tokens);
+ verifyClientSecretTokenRequest();
+ }
+
+ @Test
+ public void testInvalidAuthCode() {
+ CompletableFuture nullFuture = pkceHandler.exchangeCodeForToken(null);
+ assertTrue("Future should be completed exceptionally for null code", nullFuture.isCompletedExceptionally());
+
+ CompletableFuture emptyFuture = pkceHandler.exchangeCodeForToken(" ");
+ assertTrue("Future should be completed exceptionally for empty code", emptyFuture.isCompletedExceptionally());
+ }
+
+ // =================
+ // TOKEN REFRESH TESTS
+ // =================
+
+ @Test
+ public void testPKCETokenRefresh() throws IOException, ExecutionException, InterruptedException {
+ // Setup initial expired tokens
+ OAuthTokens expiredTokens = createExpiredTokens();
+ pkceHandler.setTokens(expiredTokens);
+
+ setupSuccessfulRefreshResponse("new_pkce_access", "new_pkce_refresh");
+
+ CompletableFuture future = pkceHandler.refreshAccessToken();
+ OAuthTokens newTokens = future.get();
+
+ verifyRefreshedTokens(newTokens, "new_pkce_access", "new_pkce_refresh");
+ verifyRefreshRequest();
+ }
+
+ @Test
+ public void testClientSecretTokenRefresh() throws IOException, ExecutionException, InterruptedException {
+ // Setup initial expired tokens
+ OAuthTokens expiredTokens = createExpiredTokens();
+ clientSecretHandler.setTokens(expiredTokens);
+
+ setupSuccessfulRefreshResponse("new_secret_access", "new_secret_refresh");
+
+ CompletableFuture future = clientSecretHandler.refreshAccessToken();
+ OAuthTokens newTokens = future.get();
+
+ verifyRefreshedTokens(newTokens, "new_secret_access", "new_secret_refresh");
+ verifyRefreshRequestWithSecret();
+ }
+
+ @Test
+ public void testRefreshWithoutTokens() {
+ CompletableFuture future = pkceHandler.refreshAccessToken();
+
+ assertTrue("Future should complete exceptionally without tokens", future.isCompletedExceptionally());
+
+ try {
+ future.get();
+ fail("Should have thrown exception for missing tokens");
+ } catch (ExecutionException | InterruptedException e) {
+ assertTrue("Should be IllegalStateException", e.getCause() instanceof IllegalStateException);
+ }
+ }
+
+
+ // =================
+ // HELPER METHODS
+ // =================
+
+ private OAuthTokens createValidTokens() {
+ OAuthTokens tokens = new OAuthTokens();
+ tokens.setAccessToken(TEST_ACCESS_TOKEN);
+ tokens.setRefreshToken(TEST_REFRESH_TOKEN);
+ tokens.setTokenType("Bearer");
+ tokens.setExpiresIn(3600L); // 1 hour
+ tokens.setScope("read write");
+ return tokens;
+ }
+
+ private OAuthTokens createExpiredTokens() {
+ OAuthTokens tokens = createValidTokens();
+ tokens.setExpiresIn(1L); // Make it expire quickly
+ // Wait to ensure expiration
+ try {
+ Thread.sleep(3500); // Wait longer than 1 second + 2 minute buffer
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ return tokens;
+ }
+
+ private void setupSuccessfulTokenResponse() throws IOException {
+ OAuthTokens expectedTokens = createValidTokens();
+ String tokenResponse = gson.toJson(expectedTokens);
+
+ when(mockHttpClient.newCall(any(Request.class))).thenReturn(mockCall);
+ when(mockCall.execute()).thenReturn(mockResponse);
+ when(mockResponse.isSuccessful()).thenReturn(true);
+ when(mockResponse.body()).thenReturn(mockResponseBody);
+ when(mockResponse.headers()).thenReturn(Headers.of());
+ when(mockResponse.code()).thenReturn(200);
+ when(mockResponseBody.string()).thenReturn(tokenResponse);
+ }
+
+ private void setupSuccessfulRefreshResponse(String newAccessToken, String newRefreshToken) throws IOException {
+ OAuthTokens refreshedTokens = createValidTokens();
+ refreshedTokens.setAccessToken(newAccessToken);
+ refreshedTokens.setRefreshToken(newRefreshToken);
+ String refreshResponse = gson.toJson(refreshedTokens);
+
+ when(mockHttpClient.newCall(any(Request.class))).thenReturn(mockCall);
+ when(mockCall.execute()).thenReturn(mockResponse);
+ when(mockResponse.isSuccessful()).thenReturn(true);
+ when(mockResponse.body()).thenReturn(mockResponseBody);
+ when(mockResponse.headers()).thenReturn(Headers.of());
+ when(mockResponse.code()).thenReturn(200);
+ when(mockResponseBody.string()).thenReturn(refreshResponse);
+ }
+
+ private void setupHttpErrorResponse(int statusCode, String errorMessage) throws IOException {
+ when(mockHttpClient.newCall(any(Request.class))).thenReturn(mockCall);
+ when(mockCall.execute()).thenReturn(mockResponse);
+ when(mockResponse.isSuccessful()).thenReturn(false);
+ when(mockResponse.code()).thenReturn(statusCode);
+ when(mockResponse.message()).thenReturn(errorMessage);
+ when(mockResponse.headers()).thenReturn(Headers.of());
+ when(mockResponse.body()).thenReturn(mockResponseBody);
+ when(mockResponseBody.string()).thenReturn(errorMessage);
+ }
+
+ private void setupMalformedJsonResponse() throws IOException {
+ when(mockHttpClient.newCall(any(Request.class))).thenReturn(mockCall);
+ when(mockCall.execute()).thenReturn(mockResponse);
+ when(mockResponse.isSuccessful()).thenReturn(true);
+ when(mockResponse.body()).thenReturn(mockResponseBody);
+ when(mockResponse.headers()).thenReturn(Headers.of());
+ when(mockResponse.code()).thenReturn(200);
+ when(mockResponseBody.string()).thenReturn("invalid json");
+ }
+
+ private void verifyTokensValid(OAuthTokens tokens) {
+ assertNotNull("Tokens should not be null", tokens);
+ assertEquals("Access token should match", TEST_ACCESS_TOKEN, tokens.getAccessToken());
+ assertEquals("Refresh token should match", TEST_REFRESH_TOKEN, tokens.getRefreshToken());
+ assertEquals("Token type should be Bearer", "Bearer", tokens.getTokenType());
+ assertTrue("Tokens should be valid", tokens.isValid());
+ }
+
+ private void verifyRefreshedTokens(OAuthTokens newTokens, String expectedAccessToken, String expectedRefreshToken) {
+ assertNotNull("New tokens should not be null", newTokens);
+ assertEquals("New access token should match", expectedAccessToken, newTokens.getAccessToken());
+ assertEquals("New refresh token should match", expectedRefreshToken, newTokens.getRefreshToken());
+ assertNotEquals("Access token should be different", TEST_ACCESS_TOKEN, newTokens.getAccessToken());
+ }
+
+ private void verifyPKCETokenRequest() {
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class);
+ verify(mockHttpClient).newCall(requestCaptor.capture());
+
+ Request capturedRequest = requestCaptor.getValue();
+ assertEquals("POST", capturedRequest.method());
+ assertTrue("URL should be token endpoint",
+ capturedRequest.url().toString().contains("/token"));
+
+ String requestBody = getRequestBody(capturedRequest);
+ assertTrue("Should contain grant_type", requestBody.contains("grant_type=authorization_code"));
+ assertTrue("Should contain code", requestBody.contains("code=" + TEST_AUTH_CODE));
+ assertTrue("Should contain code_verifier for PKCE", requestBody.contains("code_verifier="));
+ assertFalse("Should not contain client_secret", requestBody.contains("client_secret="));
+ assertTrue("Should contain client_id", requestBody.contains("client_id=" + TEST_CLIENT_ID));
+ assertTrue("Should contain redirect_uri", requestBody.contains("redirect_uri="));
+ }
+
+ private void verifyClientSecretTokenRequest() {
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class);
+ verify(mockHttpClient).newCall(requestCaptor.capture());
+
+ Request capturedRequest = requestCaptor.getValue();
+ String requestBody = getRequestBody(capturedRequest);
+ assertTrue("Should contain grant_type", requestBody.contains("grant_type=authorization_code"));
+ assertTrue("Should contain code", requestBody.contains("code=" + TEST_AUTH_CODE));
+ assertTrue("Should contain client_secret", requestBody.contains("client_secret=" + TEST_CLIENT_SECRET));
+ assertFalse("Should not contain code_verifier", requestBody.contains("code_verifier="));
+ assertTrue("Should contain client_id", requestBody.contains("client_id=" + TEST_CLIENT_ID));
+ assertTrue("Should contain redirect_uri", requestBody.contains("redirect_uri="));
+ }
+
+ private void verifyRefreshRequest() {
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class);
+ verify(mockHttpClient).newCall(requestCaptor.capture());
+
+ Request capturedRequest = requestCaptor.getValue();
+ String requestBody = getRequestBody(capturedRequest);
+ assertTrue("Should contain grant_type=refresh_token", requestBody.contains("grant_type=refresh_token"));
+ assertTrue("Should contain refresh_token", requestBody.contains("refresh_token=" + TEST_REFRESH_TOKEN));
+ }
+
+ private void verifyRefreshRequestWithSecret() {
+ verifyRefreshRequest();
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(Request.class);
+ verify(mockHttpClient).newCall(requestCaptor.capture());
+
+ Request capturedRequest = requestCaptor.getValue();
+ String requestBody = getRequestBody(capturedRequest);
+ assertTrue("Should contain client_secret", requestBody.contains("client_secret=" + TEST_CLIENT_SECRET));
+ assertTrue("Should contain client_id", requestBody.contains("client_id=" + TEST_CLIENT_ID));
+ }
+
+ private String getRequestBody(Request request) {
+ try {
+ RequestBody body = request.body();
+ if (body == null) return "";
+
+ Buffer buffer = new Buffer();
+ body.writeTo(buffer);
+ return buffer.readUtf8();
+ } catch (IOException e) {
+ return "";
+ }
+ }
+}