From 4c9f6fe62df6f4585dd01bfe447694591228a2bd Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 26 Aug 2025 15:55:30 +0530 Subject: [PATCH 01/20] Add OAuth implementation for testing --- pom.xml | 4 +- .../com/contentstack/cms/Contentstack.java | 230 ++++++++++-- .../java/com/contentstack/cms/core/Util.java | 2 +- .../contentstack/cms/models/OAuthConfig.java | 132 +++++++ .../contentstack/cms/models/OAuthTokens.java | 169 +++++++++ .../contentstack/cms/oauth/OAuthHandler.java | 353 ++++++++++++++++++ .../cms/oauth/OAuthInterceptor.java | 101 +++++ 7 files changed, 959 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/contentstack/cms/models/OAuthConfig.java create mode 100644 src/main/java/com/contentstack/cms/models/OAuthTokens.java create mode 100644 src/main/java/com/contentstack/cms/oauth/OAuthHandler.java create mode 100644 src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java diff --git a/pom.xml b/pom.xml index 95857077..43ac7c04 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.7.1 + 1.7.1-SNAPSHOT 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..396bf7e1 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -1,33 +1,43 @@ 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.Objects; +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 static com.contentstack.cms.core.Util.ILLEGAL_USER; +import static com.contentstack.cms.core.Util.PLEASE_LOGIN; 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.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 +51,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 +61,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; @@ -201,15 +213,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); + } } } @@ -435,10 +452,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("OAuth is not configured. Use Builder.setOAuth() or Builder.setOAuthWithPKCE()"); + } + 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("OAuth is not configured. Use Builder.setOAuth() or Builder.setOAuthWithPKCE()"); + } + 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("OAuth is not configured. Use Builder.setOAuth() or Builder.setOAuthWithPKCE()"); + } + 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("OAuth is not configured. Use Builder.setOAuth() or Builder.setOAuthWithPKCE()"); + } + 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 +550,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 +565,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 +714,49 @@ 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; + } + + /** + * Configures OAuth with client credentials (traditional flow) + * @param appId Application ID + * @param clientId Client ID + * @param clientSecret Client secret + * @param redirectUri Redirect URI + * @return Builder instance + */ + public Builder setOAuth(String appId, String clientId, String clientSecret, String redirectUri) { + this.oauthConfig = OAuthConfig.builder() + .appId(appId) + .clientId(clientId) + .clientSecret(clientSecret) + .redirectUri(redirectUri) + .build(); + return this; + } + + /** + * Configures OAuth with PKCE (no client secret) + * @param appId Application ID + * @param clientId Client ID + * @param redirectUri Redirect URI + * @return Builder instance + */ + public Builder setOAuthWithPKCE(String appId, String clientId, String redirectUri) { + this.oauthConfig = OAuthConfig.builder() + .appId(appId) + .clientId(clientId) + .redirectUri(redirectUri) + .build(); + return this; + } public Builder earlyAccess(String[] earlyAccess) { this.earlyAccess = earlyAccess; @@ -631,18 +780,41 @@ 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) { + this.oauthHandler = contentstack.oauthHandler = new OAuthHandler(httpClient(contentstack, this.retry), this.oauthConfig); + this.oauthInterceptor = contentstack.oauthInterceptor = new OAuthInterceptor(this.oauthHandler); + if (this.earlyAccess != null) { + this.oauthInterceptor.setEarlyAccess(this.earlyAccess); + } + } } 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) { + if (this.oauthInterceptor == null) { + this.oauthHandler = new OAuthHandler(builder.build(), this.oauthConfig); + this.oauthInterceptor = new OAuthInterceptor(this.oauthHandler); + if (this.earlyAccess != null) { + this.oauthInterceptor.setEarlyAccess(this.earlyAccess); + } + } + 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..bd8fe841 100644 --- a/src/main/java/com/contentstack/cms/core/Util.java +++ b/src/main/java/com/contentstack/cms/core/Util.java @@ -73,7 +73,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..8d99c7b4 --- /dev/null +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -0,0 +1,132 @@ +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; + +/** + * 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; + + /** + * 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"); + } + + // Validate redirectUri is a valid URL + 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() { + return authEndpoint != null ? authEndpoint : "https://app.contentstack.com/#!/apps/oauth/authorize"; + } + + /** + * Gets the formatted token endpoint URL + * @return The token endpoint URL + */ + public String getTokenEndpoint() { + return tokenEndpoint != null ? tokenEndpoint : "https://app.contentstack.com/apps/oauth/token"; + } + + /** + * 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; + } + } +} \ No newline at end of file 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..39f8d7fa --- /dev/null +++ b/src/main/java/com/contentstack/cms/models/OAuthTokens.java @@ -0,0 +1,169 @@ +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 { + @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; + + 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) { + this.expiresAt = new Date(issuedAt.getTime() + (expiresIn * 1000)); + } + } + + /** + * 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() { + if (expiresAt == null) { + return true; + } + return System.currentTimeMillis() + EXPIRY_BUFFER_MS > expiresAt.getTime(); + } + + /** + * Checks if the token is valid (has access token and not expired) + * @return true if token is valid + */ + public boolean isValid() { + return hasAccessToken() && !isExpired(); + } + + /** + * 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); + } + + /** + * Creates a copy of this token object + * @return A new OAuthTokens instance with copied values + */ + public OAuthTokens copy() { + OAuthTokens copy = new OAuthTokens(); + copy.accessToken = this.accessToken; + copy.refreshToken = this.refreshToken; + copy.tokenType = this.tokenType; + copy.expiresIn = this.expiresIn; + copy.scope = this.scope; + copy.organizationUid = this.organizationUid; + copy.userUid = this.userUid; + copy.issuedAt = this.issuedAt != null ? new Date(this.issuedAt.getTime()) : null; + copy.expiresAt = this.expiresAt != null ? new Date(this.expiresAt.getTime()) : null; + return copy; + } + + @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 + '\'' + + ", issuedAt=" + issuedAt + + ", expiresAt=" + expiresAt + + '}'; + } +} \ No newline at end of file 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..86e00bfe --- /dev/null +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -0,0 +1,353 @@ +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.concurrent.CompletableFuture; + +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 final OkHttpClient httpClient; + private final OAuthConfig config; + private final Gson gson; + + 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(); + + // Generate PKCE parameters if needed + if (config.isPkceEnabled()) { + generatePkceParameters(); + } + } + + /** + * Returns common headers for OAuth requests + */ + private Request.Builder _getHeaders() { + return new Request.Builder() + .header("Content-Type", "application/x-www-form-urlencoded"); + } + + /** + * Generates a cryptographically secure code verifier for PKCE + * @return A random URL-safe string between 43-128 characters + */ + private String generateCodeVerifier() { + SecureRandom secureRandom = new SecureRandom(); + byte[] randomBytes = new byte[32]; + secureRandom.nextBytes(randomBytes); + + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(randomBytes); + } + + /** + * 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)); + + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(hash); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } + + /** + * Generates PKCE parameters (code verifier and challenge) + */ + private void generatePkceParameters() { + this.codeVerifier = generateCodeVerifier(); + this.codeChallenge = generateCodeChallenge(this.codeVerifier); + this.state = generateCodeVerifier(); // Use same method for state + } + + /** + * Starts the OAuth authorization flow + * @return Authorization URL for the user to visit + */ + public String authorize() { + try { + StringBuilder urlBuilder = new StringBuilder(); + + // Build the authorization URL with parameters in correct order + urlBuilder.append(config.getFormattedAuthorizationEndpoint()) + .append("?app_id=").append(URLEncoder.encode(config.getAppId(), "UTF-8")) + .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("&state=").append(URLEncoder.encode(this.state, "UTF-8")); + + // Add PKCE parameters if enabled + if (config.isPkceEnabled()) { + urlBuilder.append("&code_challenge=").append(URLEncoder.encode(this.codeChallenge, "UTF-8")) + .append("&code_challenge_method=S256"); + } + + // Add scope if present + if (config.getScope() != null && !config.getScope().trim().isEmpty()) { + urlBuilder.append("&scope=").append(URLEncoder.encode(config.getScope(), "UTF-8")); + } + + 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) { + return CompletableFuture.supplyAsync(() -> { + try { + FormBody.Builder formBuilder = new FormBody.Builder() + .add("app_id", config.getAppId()) + .add("grant_type", "authorization_code") + .add("code", code) + .add("client_id", config.getClientId()) + .add("redirect_uri", config.getRedirectUri()); + + if (config.isPkceEnabled()) { + formBuilder.add("code_verifier", this.codeVerifier); + } else { + formBuilder.add("client_secret", config.getClientSecret()); + } + + 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) { + this.tokens = tokens; + } + + /** + * Refreshes the access token using the refresh token + * @return Future containing the new tokens + */ + public CompletableFuture refreshAccessToken() { + if (tokens == null || !tokens.hasRefreshToken()) { + return CompletableFuture.failedFuture( + new IllegalStateException("No refresh token available")); + } + + return CompletableFuture.supplyAsync(() -> { + try { + FormBody.Builder formBuilder = new FormBody.Builder() + .add("app_id", config.getAppId()) + .add("grant_type", "refresh_token") + .add("refresh_token", tokens.getRefreshToken()) + .add("client_id", config.getClientId()); + + if (!config.isPkceEnabled()) { + formBuilder.add("client_secret", config.getClientSecret()); + } + + Request request = _getHeaders() + .url(config.getTokenEndpoint()) + .post(formBuilder.build()) + .build(); + + return executeTokenRequest(request); + } catch (IOException | RuntimeException e) { + throw new RuntimeException("Failed to refresh tokens", 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"; + throw new RuntimeException("Token request failed: " + error); + } + + String body = responseBody != null ? responseBody.string() : "{}"; + OAuthTokens newTokens = gson.fromJson(body, OAuthTokens.class); + + // Keep old refresh token if new one not provided + if (this.tokens != null && newTokens.getRefreshToken() == null) { + 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(); + } + } + } + + /** + * Handles the OAuth redirect by exchanging the code for tokens + * @param code Authorization code from the redirect + * @return Future containing the tokens + */ + public CompletableFuture handleRedirect(String code) { + return exchangeCodeForToken(code); + } + + /** + * 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("Failed to get authorization status: " + 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("Failed to revoke authorization: " + 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() { return tokens != null ? tokens.getAccessToken() : null; } + public String getRefreshToken() { return tokens != null ? tokens.getRefreshToken() : null; } + public String getOrganizationUID() { return tokens != null ? tokens.getOrganizationUid() : null; } + public String getUserUID() { return tokens != null ? tokens.getUserUid() : null; } + public Long getTokenExpiryTime() { return tokens != null ? tokens.getExpiresIn() : null; } +} \ No newline at end of file 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..37c8eac9 --- /dev/null +++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java @@ -0,0 +1,101 @@ +package com.contentstack.cms.oauth; + +import com.contentstack.cms.core.Util; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +import java.io.IOException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.logging.Logger; + +/** + * OkHttp interceptor that handles OAuth token injection and refresh + */ +public class OAuthInterceptor implements Interceptor { + private static final Logger logger = Logger.getLogger(OAuthInterceptor.class.getName()); + private final OAuthHandler oauthHandler; + private String[] earlyAccess; + + public OAuthInterceptor(OAuthHandler oauthHandler) { + this.oauthHandler = oauthHandler; + } + + /** + * Sets early access features + * @param earlyAccess Array of early access feature names + */ + public void setEarlyAccess(String[] earlyAccess) { + this.earlyAccess = earlyAccess; + } + + /** + * Checks if OAuth is configured + * @return true if OAuth is configured + */ + public boolean isOAuthConfigured() { + return oauthHandler != null && oauthHandler.getConfig() != null; + } + + /** + * Checks if we have valid tokens + * @return true if we have valid tokens + */ + public boolean hasValidTokens() { + return oauthHandler != null && + oauthHandler.getTokens() != null && + !oauthHandler.getTokens().isExpired(); + } + + @Override + public Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + + // Add standard headers + Request.Builder requestBuilder = originalRequest.newBuilder() + .header("X-User-Agent", Util.defaultUserAgent()) + .header("User-Agent", Util.defaultUserAgent()) + .header("Content-Type", "application/json") + .header("X-Header-EA", earlyAccess != null ? String.join(",", earlyAccess) : "true"); + + // Get current tokens + if (oauthHandler.getTokens() != null) { + // Check if token is expired and refresh if needed + if (oauthHandler.getTokens().isExpired() && oauthHandler.getTokens().hasRefreshToken()) { + try { + oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new IOException("Failed to refresh access token", e); + } + } + + // Add token to request if available + if (oauthHandler.getTokens().hasAccessToken()) { + requestBuilder.header("Authorization", "Bearer " + oauthHandler.getAccessToken()); + } + } + + Request request = requestBuilder.build(); + Response response = chain.proceed(request); + + // Handle 401 by refreshing token and retrying once + if (response.code() == 401 && oauthHandler.getTokens() != null && oauthHandler.getTokens().hasRefreshToken()) { + response.close(); + try { + oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS); + + // Retry with new token + request = request.newBuilder() + .header("Authorization", "Bearer " + oauthHandler.getAccessToken()) + .build(); + return chain.proceed(request); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new IOException("Failed to refresh access token after 401", e); + } + } + + return response; + } +} \ No newline at end of file From b5ef7244e5ff47967d99e8dff4b2375ed93c5a28 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 26 Aug 2025 17:53:29 +0530 Subject: [PATCH 02/20] fix: Generate state parameter in constructor for OAuth security --- src/main/java/com/contentstack/cms/oauth/OAuthHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index 86e00bfe..a6873a65 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -51,6 +51,7 @@ public OAuthHandler(OkHttpClient httpClient, OAuthConfig config) { // Validate config before proceeding config.validate(); + this.state = generateCodeVerifier(); // Generate PKCE parameters if needed if (config.isPkceEnabled()) { @@ -104,7 +105,7 @@ private String generateCodeChallenge(String verifier) { private void generatePkceParameters() { this.codeVerifier = generateCodeVerifier(); this.codeChallenge = generateCodeChallenge(this.codeVerifier); - this.state = generateCodeVerifier(); // Use same method for state + } /** From 9544846976462500d942e942bca7ae4f8a00d20d Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Thu, 28 Aug 2025 18:42:25 +0530 Subject: [PATCH 03/20] fix: Update OAuth implementation --- .../contentstack/cms/models/OAuthConfig.java | 31 +++++++++++++++++-- .../contentstack/cms/oauth/OAuthHandler.java | 8 +++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index 8d99c7b4..b331c0d7 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -59,7 +59,22 @@ public boolean isPkceEnabled() { * @return The authorization endpoint URL */ public String getFormattedAuthorizationEndpoint() { - return authEndpoint != null ? authEndpoint : "https://app.contentstack.com/#!/apps/oauth/authorize"; + if (authEndpoint != null) { + return authEndpoint; + } + + // Transform hostname similar to JS SDK + String hostname = "app.contentstack.com"; + + // Handle environment-specific transformations + if (hostname.endsWith("io")) { + hostname = hostname.replace("io", "com"); + } + if (hostname.startsWith("api")) { + hostname = hostname.replace("api", "app"); + } + + return "https://" + hostname + "/#!/apps/oauth/authorize"; } /** @@ -67,7 +82,19 @@ public String getFormattedAuthorizationEndpoint() { * @return The token endpoint URL */ public String getTokenEndpoint() { - return tokenEndpoint != null ? tokenEndpoint : "https://app.contentstack.com/apps/oauth/token"; + if (tokenEndpoint != null) { + return tokenEndpoint; + } + + // Transform for developer hub + String hostname = "developerhub-api.contentstack.com"; + + // Handle environment-specific transformations + hostname = hostname + .replaceAll("^dev\\d+", "dev") // Replace dev1, dev2, etc. with dev + .replace("io", "com"); + + return "https://" + hostname + "/apps/oauth/token"; } /** diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index a6873a65..5bb5420f 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -121,8 +121,12 @@ public String authorize() { .append("?app_id=").append(URLEncoder.encode(config.getAppId(), "UTF-8")) .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("&state=").append(URLEncoder.encode(this.state, "UTF-8")); + .append("&redirect_uri=").append(URLEncoder.encode(config.getRedirectUri(), "UTF-8")); + + // Add state for CSRF protection (always needed) + if (this.state != null) { + urlBuilder.append("&state=").append(URLEncoder.encode(this.state, "UTF-8")); + } // Add PKCE parameters if enabled if (config.isPkceEnabled()) { From c0d614d86a7e97fae370b3043b635ce84719c106 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Thu, 28 Aug 2025 18:46:19 +0530 Subject: [PATCH 04/20] fix: Update OAuth implementation 1 --- src/main/java/com/contentstack/cms/models/OAuthConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index b331c0d7..1032dacb 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -74,7 +74,7 @@ public String getFormattedAuthorizationEndpoint() { hostname = hostname.replace("api", "app"); } - return "https://" + hostname + "/#!/apps/oauth/authorize"; + return "https://" + hostname + "/apps/oauth/authorize"; } /** From d283ea63ebd6a9df2a67bfe0c63783e4119991d3 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Thu, 28 Aug 2025 19:25:24 +0530 Subject: [PATCH 05/20] fix: Update OAuth implementation 2 --- .../contentstack/cms/models/OAuthConfig.java | 4 +- .../contentstack/cms/oauth/OAuthHandler.java | 80 +++++++++---------- 2 files changed, 39 insertions(+), 45 deletions(-) diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index 1032dacb..eb29327e 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -74,7 +74,7 @@ public String getFormattedAuthorizationEndpoint() { hostname = hostname.replace("api", "app"); } - return "https://" + hostname + "/apps/oauth/authorize"; + return "https://" + hostname + "/#!/apps/" + appId + "/authorize"; } /** @@ -94,7 +94,7 @@ public String getTokenEndpoint() { .replaceAll("^dev\\d+", "dev") // Replace dev1, dev2, etc. with dev .replace("io", "com"); - return "https://" + hostname + "/apps/oauth/token"; + return "https://" + hostname; } /** diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index 5bb5420f..6b6fb35f 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -51,11 +51,12 @@ public OAuthHandler(OkHttpClient httpClient, OAuthConfig config) { // Validate config before proceeding config.validate(); - this.state = generateCodeVerifier(); - // Generate PKCE parameters if needed - if (config.isPkceEnabled()) { - generatePkceParameters(); + // Only generate PKCE codeVerifier if clientSecret is not provided + if (config.getClientSecret() == null || config.getClientSecret().trim().isEmpty()) { + this.codeVerifier = generateCodeVerifier(); + // codeChallenge will be generated during authorize() + this.codeChallenge = null; } } @@ -72,13 +73,13 @@ private Request.Builder _getHeaders() { * @return A random URL-safe string between 43-128 characters */ private String generateCodeVerifier() { - SecureRandom secureRandom = new SecureRandom(); - byte[] randomBytes = new byte[32]; - secureRandom.nextBytes(randomBytes); - - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(randomBytes); + final String charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; + SecureRandom random = new SecureRandom(); + StringBuilder verifier = new StringBuilder(); + for (int i = 0; i < 128; i++) { + verifier.append(charset.charAt(random.nextInt(charset.length()))); + } + return verifier.toString(); } /** @@ -91,9 +92,11 @@ private String generateCodeChallenge(String verifier) { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(verifier.getBytes(StandardCharsets.UTF_8)); - return Base64.getUrlEncoder() - .withoutPadding() - .encodeToString(hash); + String base64String = Base64.getEncoder().encodeToString(hash); + return base64String + .replace('+', '-') + .replace('/', '_') + .replaceAll("=+$", ""); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("SHA-256 algorithm not available", e); } @@ -114,32 +117,23 @@ private void generatePkceParameters() { */ public String authorize() { try { - StringBuilder urlBuilder = new StringBuilder(); - - // Build the authorization URL with parameters in correct order - urlBuilder.append(config.getFormattedAuthorizationEndpoint()) - .append("?app_id=").append(URLEncoder.encode(config.getAppId(), "UTF-8")) - .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")); - - // Add state for CSRF protection (always needed) - if (this.state != null) { - urlBuilder.append("&state=").append(URLEncoder.encode(this.state, "UTF-8")); - } - - // Add PKCE parameters if enabled - if (config.isPkceEnabled()) { + String baseUrl = String.format("%s/#!/apps/%s/authorize", + config.getFormattedAuthorizationEndpoint(), + config.getAppId()); + + StringBuilder urlBuilder = new StringBuilder(baseUrl); + urlBuilder.append("?response_type=").append(config.getResponseType()) + .append("&client_id=").append(URLEncoder.encode(config.getClientId(), "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(); } - - // Add scope if present - if (config.getScope() != null && !config.getScope().trim().isEmpty()) { - urlBuilder.append("&scope=").append(URLEncoder.encode(config.getScope(), "UTF-8")); - } - - return urlBuilder.toString(); } catch (IOException e) { throw new RuntimeException("Failed to encode URL parameters", e); } @@ -154,16 +148,16 @@ public CompletableFuture exchangeCodeForToken(String code) { return CompletableFuture.supplyAsync(() -> { try { FormBody.Builder formBuilder = new FormBody.Builder() - .add("app_id", config.getAppId()) .add("grant_type", "authorization_code") .add("code", code) - .add("client_id", config.getClientId()) - .add("redirect_uri", config.getRedirectUri()); + .add("redirect_uri", config.getRedirectUri()) + .add("client_id", config.getClientId()); - if (config.isPkceEnabled()) { - formBuilder.add("code_verifier", this.codeVerifier); - } else { + // 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() From 9256fa0f3ecb9e4d57d09969edfc3f0f826d15c0 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Thu, 28 Aug 2025 19:35:20 +0530 Subject: [PATCH 06/20] fix: Update OAuth implementation 3 --- src/main/java/com/contentstack/cms/oauth/OAuthHandler.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index 6b6fb35f..b4759371 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -117,9 +117,7 @@ private void generatePkceParameters() { */ public String authorize() { try { - String baseUrl = String.format("%s/#!/apps/%s/authorize", - config.getFormattedAuthorizationEndpoint(), - config.getAppId()); + String baseUrl = config.getFormattedAuthorizationEndpoint(); StringBuilder urlBuilder = new StringBuilder(baseUrl); urlBuilder.append("?response_type=").append(config.getResponseType()) From 99ca31fcd7fccbe53ff6b694be9eff3ca5ea7801 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Thu, 28 Aug 2025 20:01:34 +0530 Subject: [PATCH 07/20] fix: Update OAuth implementation 4 --- .../contentstack/cms/models/OAuthConfig.java | 2 +- .../contentstack/cms/oauth/OAuthHandler.java | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index eb29327e..b1641117 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -94,7 +94,7 @@ public String getTokenEndpoint() { .replaceAll("^dev\\d+", "dev") // Replace dev1, dev2, etc. with dev .replace("io", "com"); - return "https://" + hostname; + return "https://" + hostname + "/apps/oauth/token"; } /** diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index b4759371..550d4cfd 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -121,7 +121,8 @@ public String authorize() { StringBuilder urlBuilder = new StringBuilder(baseUrl); urlBuilder.append("?response_type=").append(config.getResponseType()) - .append("&client_id=").append(URLEncoder.encode(config.getClientId(), "UTF-8")); + .append("&client_id=").append(URLEncoder.encode(config.getClientId(), "UTF-8")) + .append("&redirect_uri=").append(URLEncoder.encode(config.getRedirectUri(), "UTF-8")); if (config.getClientSecret() != null && !config.getClientSecret().trim().isEmpty()) { return urlBuilder.toString(); @@ -216,18 +217,30 @@ public CompletableFuture refreshAccessToken() { * Executes a token request and processes the response */ private OAuthTokens executeTokenRequest(Request request) throws IOException { + System.out.println("\nToken Request Details:"); + System.out.println("URL: " + request.url()); + System.out.println("Method: " + request.method()); + System.out.println("Headers: " + request.headers()); + Response response = null; ResponseBody responseBody = null; try { response = httpClient.newCall(request).execute(); responseBody = response.body(); + System.out.println("\nToken Response Details:"); + System.out.println("Status Code: " + response.code()); + System.out.println("Headers: " + response.headers()); + if (!response.isSuccessful()) { String error = responseBody != null ? responseBody.string() : "Unknown error"; - throw new RuntimeException("Token request failed: " + error); + System.err.println("Error Response Body: " + error); + throw new RuntimeException("Token request failed with status " + response.code() + ": " + error); } String body = responseBody != null ? responseBody.string() : "{}"; + System.out.println("Success Response Body: " + body); + OAuthTokens newTokens = gson.fromJson(body, OAuthTokens.class); // Keep old refresh token if new one not provided From 4c8908967ccc5b1c035cc3c67d0898652f7a5346 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Thu, 28 Aug 2025 20:05:40 +0530 Subject: [PATCH 08/20] fix: Update OAuth implementation 5 --- src/main/java/com/contentstack/cms/models/OAuthConfig.java | 2 +- src/main/java/com/contentstack/cms/oauth/OAuthHandler.java | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index b1641117..91557083 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -94,7 +94,7 @@ public String getTokenEndpoint() { .replaceAll("^dev\\d+", "dev") // Replace dev1, dev2, etc. with dev .replace("io", "com"); - return "https://" + hostname + "/apps/oauth/token"; + return "https://" + hostname + "/token"; } /** diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index 550d4cfd..0d92e340 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -65,7 +65,8 @@ public OAuthHandler(OkHttpClient httpClient, OAuthConfig config) { */ private Request.Builder _getHeaders() { return new Request.Builder() - .header("Content-Type", "application/x-www-form-urlencoded"); + .header("Content-Type", "application/x-www-form-urlencoded") + .header("authorization", "Bearer " + (tokens != null ? tokens.getAccessToken() : "")); } /** @@ -150,7 +151,8 @@ public CompletableFuture exchangeCodeForToken(String code) { .add("grant_type", "authorization_code") .add("code", code) .add("redirect_uri", config.getRedirectUri()) - .add("client_id", config.getClientId()); + .add("client_id", config.getClientId()) + .add("app_id", config.getAppId()); // Choose between client_secret and code_verifier like JS SDK if (config.getClientSecret() != null) { From 258fa671deac2e5318e74b00c97bad9740637be7 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 29 Aug 2025 10:17:58 +0530 Subject: [PATCH 09/20] fix: Update OAuth implementation 6 --- .../java/com/contentstack/cms/oauth/OAuthHandler.java | 11 +++++++++++ .../com/contentstack/cms/oauth/OAuthInterceptor.java | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index 0d92e340..024d8eab 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -179,6 +179,17 @@ public CompletableFuture exchangeCodeForToken(String code) { */ private void _saveTokens(OAuthTokens tokens) { this.tokens = tokens; + // Update the client's auth state + if (tokens != null && tokens.hasAccessToken()) { + this.httpClient = this.httpClient.newBuilder() + .addInterceptor(chain -> { + Request request = chain.request().newBuilder() + .header("Authorization", "Bearer " + tokens.getAccessToken()) + .build(); + return chain.proceed(request); + }) + .build(); + } } /** diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java index 37c8eac9..caa45eaa 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java @@ -57,7 +57,7 @@ public Response intercept(Chain chain) throws IOException { Request.Builder requestBuilder = originalRequest.newBuilder() .header("X-User-Agent", Util.defaultUserAgent()) .header("User-Agent", Util.defaultUserAgent()) - .header("Content-Type", "application/json") + .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"); // Get current tokens From b0c5f101af587ba8bbc63c1a437a35cc3697f412 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 29 Aug 2025 10:36:24 +0530 Subject: [PATCH 10/20] fix: Update OAuth implementation 7 --- .../contentstack/cms/models/OAuthConfig.java | 8 +--- .../contentstack/cms/oauth/OAuthHandler.java | 43 ++++++++----------- .../cms/oauth/OAuthInterceptor.java | 30 ++----------- 3 files changed, 22 insertions(+), 59 deletions(-) diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index 91557083..107ccaef 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -38,7 +38,6 @@ public void validate() { throw new IllegalArgumentException("redirectUri is required"); } - // Validate redirectUri is a valid URL try { new URL(redirectUri); } catch (MalformedURLException e) { @@ -63,10 +62,8 @@ public String getFormattedAuthorizationEndpoint() { return authEndpoint; } - // Transform hostname similar to JS SDK String hostname = "app.contentstack.com"; - // Handle environment-specific transformations if (hostname.endsWith("io")) { hostname = hostname.replace("io", "com"); } @@ -86,12 +83,9 @@ public String getTokenEndpoint() { return tokenEndpoint; } - // Transform for developer hub String hostname = "developerhub-api.contentstack.com"; - - // Handle environment-specific transformations hostname = hostname - .replaceAll("^dev\\d+", "dev") // Replace dev1, dev2, etc. with dev + .replaceAll("^dev\\d+", "dev") .replace("io", "com"); return "https://" + hostname + "/token"; diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index 024d8eab..93e385f7 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -28,7 +28,7 @@ */ @Getter public class OAuthHandler { - private final OkHttpClient httpClient; + private OkHttpClient httpClient; private final OAuthConfig config; private final Gson gson; @@ -55,14 +55,11 @@ public OAuthHandler(OkHttpClient httpClient, OAuthConfig config) { // Only generate PKCE codeVerifier if clientSecret is not provided if (config.getClientSecret() == null || config.getClientSecret().trim().isEmpty()) { this.codeVerifier = generateCodeVerifier(); - // codeChallenge will be generated during authorize() this.codeChallenge = null; } } - /** - * Returns common headers for OAuth requests - */ + private Request.Builder _getHeaders() { return new Request.Builder() .header("Content-Type", "application/x-www-form-urlencoded") @@ -74,10 +71,11 @@ private Request.Builder _getHeaders() { * @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 < 128; i++) { + for (int i = 0; i < CODE_VERIFIER_LENGTH; i++) { verifier.append(charset.charAt(random.nextInt(charset.length()))); } return verifier.toString(); @@ -103,14 +101,6 @@ private String generateCodeChallenge(String verifier) { } } - /** - * Generates PKCE parameters (code verifier and challenge) - */ - private void generatePkceParameters() { - this.codeVerifier = generateCodeVerifier(); - this.codeChallenge = generateCodeChallenge(this.codeVerifier); - - } /** * Starts the OAuth authorization flow @@ -124,6 +114,11 @@ public String authorize() { 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")); + + // 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(); @@ -179,17 +174,6 @@ public CompletableFuture exchangeCodeForToken(String code) { */ private void _saveTokens(OAuthTokens tokens) { this.tokens = tokens; - // Update the client's auth state - if (tokens != null && tokens.hasAccessToken()) { - this.httpClient = this.httpClient.newBuilder() - .addInterceptor(chain -> { - Request request = chain.request().newBuilder() - .header("Authorization", "Bearer " + tokens.getAccessToken()) - .build(); - return chain.proceed(request); - }) - .build(); - } } /** @@ -248,7 +232,14 @@ private OAuthTokens executeTokenRequest(Request request) throws IOException { if (!response.isSuccessful()) { String error = responseBody != null ? responseBody.string() : "Unknown error"; System.err.println("Error Response Body: " + error); - throw new RuntimeException("Token request failed with status " + response.code() + ": " + error); + + // Parse error response if possible + try { + throw new RuntimeException("Token request failed: " + error); + } catch (JsonParseException e) { + throw new RuntimeException("Token request failed with status " + + response.code() + ": " + error); + } } String body = responseBody != null ? responseBody.string() : "{}"; diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java index caa45eaa..c7cc6488 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java @@ -11,9 +11,7 @@ import java.util.concurrent.TimeoutException; import java.util.logging.Logger; -/** - * OkHttp interceptor that handles OAuth token injection and refresh - */ + public class OAuthInterceptor implements Interceptor { private static final Logger logger = Logger.getLogger(OAuthInterceptor.class.getName()); private final OAuthHandler oauthHandler; @@ -23,26 +21,17 @@ public OAuthInterceptor(OAuthHandler oauthHandler) { this.oauthHandler = oauthHandler; } - /** - * Sets early access features - * @param earlyAccess Array of early access feature names - */ + public void setEarlyAccess(String[] earlyAccess) { this.earlyAccess = earlyAccess; } - /** - * Checks if OAuth is configured - * @return true if OAuth is configured - */ + public boolean isOAuthConfigured() { return oauthHandler != null && oauthHandler.getConfig() != null; } - /** - * Checks if we have valid tokens - * @return true if we have valid tokens - */ + public boolean hasValidTokens() { return oauthHandler != null && oauthHandler.getTokens() != null && @@ -52,17 +41,12 @@ public boolean hasValidTokens() { @Override public Response intercept(Chain chain) throws IOException { Request originalRequest = chain.request(); - - // Add standard headers 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"); - - // Get current tokens if (oauthHandler.getTokens() != null) { - // Check if token is expired and refresh if needed if (oauthHandler.getTokens().isExpired() && oauthHandler.getTokens().hasRefreshToken()) { try { oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS); @@ -70,8 +54,6 @@ public Response intercept(Chain chain) throws IOException { throw new IOException("Failed to refresh access token", e); } } - - // Add token to request if available if (oauthHandler.getTokens().hasAccessToken()) { requestBuilder.header("Authorization", "Bearer " + oauthHandler.getAccessToken()); } @@ -79,14 +61,10 @@ public Response intercept(Chain chain) throws IOException { Request request = requestBuilder.build(); Response response = chain.proceed(request); - - // Handle 401 by refreshing token and retrying once if (response.code() == 401 && oauthHandler.getTokens() != null && oauthHandler.getTokens().hasRefreshToken()) { response.close(); try { oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS); - - // Retry with new token request = request.newBuilder() .header("Authorization", "Bearer " + oauthHandler.getAccessToken()) .build(); From a02f16174c4f7d7ab14d578166db6b10e8bf2b55 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 29 Aug 2025 10:48:32 +0530 Subject: [PATCH 11/20] fix: Update OAuth implementation 8 --- .../com/contentstack/cms/Contentstack.java | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 396bf7e1..d65af4d9 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -98,8 +98,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("Please login or configure OAuth to access user"); + } user = new User(this.instance); return user; } @@ -291,7 +292,9 @@ 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("Please login or configure OAuth to access organization"); + } return new Organization(this.instance); } @@ -315,9 +318,12 @@ public Organization organization() { * */ public Organization organization(@NotNull String organizationUid) { - Objects.requireNonNull(this.authtoken, "Please Login to access user instance"); - if (organizationUid.isEmpty()) + if (!isOAuthConfigured() && this.authtoken == null) { + throw new IllegalStateException("Please login or configure OAuth to access organization"); + } + if (organizationUid.isEmpty()) { throw new IllegalStateException("organizationUid can not be empty"); + } return new Organization(this.instance, organizationUid); } @@ -339,7 +345,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("Please login or configure OAuth to access stack"); + } return new Stack(this.instance); } @@ -362,8 +370,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("Please login or configure OAuth to access stack"); + } return new Stack(this.instance, header); } From 26442667109c348185407550eda4188b0709e957 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 29 Aug 2025 11:03:52 +0530 Subject: [PATCH 12/20] fix: Update OAuth implementation 9 --- .../com/contentstack/cms/Contentstack.java | 23 +++++++++---- .../contentstack/cms/models/OAuthConfig.java | 20 +++++++----- .../contentstack/cms/models/OAuthTokens.java | 13 ++++++++ .../contentstack/cms/oauth/OAuthHandler.java | 31 ++++++++++++++---- .../cms/oauth/OAuthInterceptor.java | 32 +++++++++++++------ 5 files changed, 90 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index d65af4d9..5a6b0555 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -295,6 +295,15 @@ public Organization organization() { if (!isOAuthConfigured() && this.authtoken == null) { throw new IllegalStateException("Please login or configure OAuth to access 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); } @@ -810,13 +819,15 @@ private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailur // Add either OAuth or traditional auth interceptor if (this.oauthConfig != null) { - if (this.oauthInterceptor == null) { - this.oauthHandler = new OAuthHandler(builder.build(), this.oauthConfig); - this.oauthInterceptor = new OAuthInterceptor(this.oauthHandler); - if (this.earlyAccess != null) { - this.oauthInterceptor.setEarlyAccess(this.earlyAccess); - } + // Create OAuth handler and interceptor first + OkHttpClient tempClient = builder.build(); + this.oauthHandler = new OAuthHandler(tempClient, this.oauthConfig); + this.oauthInterceptor = new OAuthInterceptor(this.oauthHandler); + if (this.earlyAccess != null) { + this.oauthInterceptor.setEarlyAccess(this.earlyAccess); } + + // Add interceptor to final client builder.addInterceptor(this.oauthInterceptor); } else { this.authInterceptor = contentstack.interceptor = new AuthInterceptor(); diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index 107ccaef..247f5285 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -64,11 +64,11 @@ public String getFormattedAuthorizationEndpoint() { String hostname = "app.contentstack.com"; - if (hostname.endsWith("io")) { - hostname = hostname.replace("io", "com"); - } - if (hostname.startsWith("api")) { - hostname = hostname.replace("api", "app"); + // Transform hostname if needed + if (hostname.contains("contentstack")) { + hostname = hostname + .replaceAll("^api\\.", "app.") // api.contentstack -> app.contentstack + .replaceAll("\\.io$", ".com"); // *.io -> *.com } return "https://" + hostname + "/#!/apps/" + appId + "/authorize"; @@ -84,9 +84,13 @@ public String getTokenEndpoint() { } String hostname = "developerhub-api.contentstack.com"; - hostname = hostname - .replaceAll("^dev\\d+", "dev") - .replace("io", "com"); + + // Transform hostname if needed + if (hostname.contains("contentstack")) { + hostname = hostname + .replaceAll("^dev\\d+\\.", "dev.") // dev1.* -> dev.* + .replaceAll("\\.io$", ".com"); // *.io -> *.com + } return "https://" + hostname + "/token"; } diff --git a/src/main/java/com/contentstack/cms/models/OAuthTokens.java b/src/main/java/com/contentstack/cms/models/OAuthTokens.java index 39f8d7fa..6b3d1e19 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthTokens.java +++ b/src/main/java/com/contentstack/cms/models/OAuthTokens.java @@ -35,6 +35,9 @@ public class OAuthTokens { @SerializedName("user_uid") private String userUid; + @SerializedName("stack_api_key") + private String stackApiKey; + private Date issuedAt; private Date expiresAt; @@ -147,11 +150,20 @@ public OAuthTokens copy() { copy.scope = this.scope; copy.organizationUid = this.organizationUid; copy.userUid = this.userUid; + copy.stackApiKey = this.stackApiKey; copy.issuedAt = this.issuedAt != null ? new Date(this.issuedAt.getTime()) : null; copy.expiresAt = this.expiresAt != null ? new Date(this.expiresAt.getTime()) : null; return copy; } + /** + * Gets the stack API key if available + * @return The stack API key or null if not available + */ + public String getStackApiKey() { + return stackApiKey != null && !stackApiKey.trim().isEmpty() ? stackApiKey : null; + } + @Override public String toString() { return "OAuthTokens{" + @@ -162,6 +174,7 @@ public String toString() { ", 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 index 93e385f7..0a06bd1a 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -61,9 +61,14 @@ public OAuthHandler(OkHttpClient httpClient, OAuthConfig config) { private Request.Builder _getHeaders() { - return new Request.Builder() - .header("Content-Type", "application/x-www-form-urlencoded") - .header("authorization", "Bearer " + (tokens != null ? tokens.getAccessToken() : "")); + 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; } /** @@ -194,8 +199,11 @@ public CompletableFuture refreshAccessToken() { .add("refresh_token", tokens.getRefreshToken()) .add("client_id", config.getClientId()); - if (!config.isPkceEnabled()) { + // 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 = _getHeaders() @@ -233,10 +241,13 @@ private OAuthTokens executeTokenRequest(Request request) throws IOException { String error = responseBody != null ? responseBody.string() : "Unknown error"; System.err.println("Error Response Body: " + error); - // Parse error response if possible + // Try to parse error as JSON for better error message try { - throw new RuntimeException("Token request failed: " + error); + 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); } @@ -364,4 +375,12 @@ public CompletableFuture revokeOauthAppAuthorization() { public String getOrganizationUID() { return tokens != null ? tokens.getOrganizationUid() : null; } public String getUserUID() { return tokens != null ? tokens.getUserUid() : null; } public Long getTokenExpiryTime() { return tokens != null ? tokens.getExpiresIn() : null; } + + /** + * Checks if we have a valid access token + * @return true if we have a non-expired access token + */ + public boolean hasValidAccessToken() { + return tokens != null && tokens.hasAccessToken() && !tokens.isExpired(); + } } \ No newline at end of file diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java index c7cc6488..18c4c4d6 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java @@ -41,21 +41,35 @@ public boolean hasValidTokens() { @Override public Response intercept(Chain chain) throws IOException { Request originalRequest = chain.request(); + + System.out.println("\nOAuth Interceptor - Request Details:"); + System.out.println("URL: " + originalRequest.url()); + System.out.println("Method: " + originalRequest.method()); + System.out.println("Has Tokens: " + (oauthHandler.getTokens() != null)); + if (oauthHandler.getTokens() != null) { + System.out.println("Access Token: " + oauthHandler.getTokens().getAccessToken()); + System.out.println("Token Expired: " + oauthHandler.getTokens().isExpired()); + } + 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"); - if (oauthHandler.getTokens() != null) { - if (oauthHandler.getTokens().isExpired() && oauthHandler.getTokens().hasRefreshToken()) { - try { - oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS); - } catch (InterruptedException | ExecutionException | TimeoutException e) { - throw new IOException("Failed to refresh access token", e); + // Skip auth header for token endpoints + if (!originalRequest.url().toString().contains("/token")) { + if (oauthHandler.getTokens() != null) { + if (oauthHandler.getTokens().isExpired() && oauthHandler.getTokens().hasRefreshToken()) { + try { + oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + throw new IOException("Failed to refresh access token", e); + } + } + if (oauthHandler.getTokens().hasAccessToken()) { + requestBuilder.header("Authorization", "Bearer " + oauthHandler.getAccessToken()); + System.out.println("Added Authorization header: Bearer " + oauthHandler.getAccessToken()); } - } - if (oauthHandler.getTokens().hasAccessToken()) { - requestBuilder.header("Authorization", "Bearer " + oauthHandler.getAccessToken()); } } From f688b3deb66df61e035d2e2bc82831af7e9cea6f Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Fri, 29 Aug 2025 12:09:47 +0530 Subject: [PATCH 13/20] fix: Refactor OAuth handling and improve token management --- .../com/contentstack/cms/Contentstack.java | 12 +- .../contentstack/cms/models/OAuthTokens.java | 38 +++++- .../contentstack/cms/oauth/OAuthHandler.java | 93 ++++++++++++--- .../cms/oauth/OAuthInterceptor.java | 108 ++++++++++++++---- 4 files changed, 204 insertions(+), 47 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 5a6b0555..3b6e2e69 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -801,11 +801,9 @@ private void validateClient(Contentstack contentstack) { // Initialize OAuth if configured if (this.oauthConfig != null) { - this.oauthHandler = contentstack.oauthHandler = new OAuthHandler(httpClient(contentstack, this.retry), this.oauthConfig); - this.oauthInterceptor = contentstack.oauthInterceptor = new OAuthInterceptor(this.oauthHandler); - if (this.earlyAccess != null) { - this.oauthInterceptor.setEarlyAccess(this.earlyAccess); - } + // OAuth handler and interceptor are created in httpClient + contentstack.oauthHandler = this.oauthHandler; + contentstack.oauthInterceptor = this.oauthInterceptor; } } @@ -823,11 +821,13 @@ private OkHttpClient httpClient(Contentstack contentstack, Boolean retryOnFailur 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 final client + // Add interceptor to handle OAuth, token refresh, and retries builder.addInterceptor(this.oauthInterceptor); } else { this.authInterceptor = contentstack.interceptor = new AuthInterceptor(); diff --git a/src/main/java/com/contentstack/cms/models/OAuthTokens.java b/src/main/java/com/contentstack/cms/models/OAuthTokens.java index 6b3d1e19..d46e07a9 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthTokens.java +++ b/src/main/java/com/contentstack/cms/models/OAuthTokens.java @@ -14,6 +14,7 @@ @Getter @Setter public class OAuthTokens { + private static final String BEARER_TOKEN_TYPE = "Bearer"; @SerializedName("access_token") private String accessToken; @@ -54,10 +55,25 @@ public OAuthTokens() { public void setExpiresIn(Long expiresIn) { this.expiresIn = expiresIn; if (expiresIn != null) { - this.expiresAt = new Date(issuedAt.getTime() + (expiresIn * 1000)); + 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 @@ -95,18 +111,32 @@ public boolean hasScope(String scopeToCheck) { * @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; } - return System.currentTimeMillis() + EXPIRY_BUFFER_MS > expiresAt.getTime(); + + // 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 boolean isValid() { - return hasAccessToken() && !isExpired(); + public synchronized boolean isValid() { + return hasAccessToken() && !isExpired() && + BEARER_TOKEN_TYPE.equalsIgnoreCase(tokenType); } /** diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index 0a06bd1a..9cba329d 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -7,6 +7,7 @@ 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.models.OAuthConfig; @@ -31,6 +32,7 @@ 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; @@ -118,7 +120,8 @@ public String authorize() { 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("&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()) { @@ -145,11 +148,16 @@ public String authorize() { * @return Future containing the tokens */ public CompletableFuture exchangeCodeForToken(String code) { + if (code == null || code.trim().isEmpty()) { + return CompletableFuture.failedFuture(new IllegalArgumentException("Authorization code cannot be null or empty")); + } + + System.out.println("\nExchanging authorization code for tokens..."); return CompletableFuture.supplyAsync(() -> { try { FormBody.Builder formBuilder = new FormBody.Builder() .add("grant_type", "authorization_code") - .add("code", code) + .add("code", code.trim()) .add("redirect_uri", config.getRedirectUri()) .add("client_id", config.getClientId()) .add("app_id", config.getAppId()); @@ -178,7 +186,15 @@ public CompletableFuture exchangeCodeForToken(String code) { * @param tokens The tokens to save */ private void _saveTokens(OAuthTokens tokens) { - this.tokens = tokens; + synchronized (tokenLock) { + this.tokens = tokens; + } + } + + private OAuthTokens _getTokens() { + synchronized (tokenLock) { + return this.tokens; + } } /** @@ -186,18 +202,33 @@ private void _saveTokens(OAuthTokens tokens) { * @return Future containing the new tokens */ public CompletableFuture refreshAccessToken() { - if (tokens == null || !tokens.hasRefreshToken()) { + // Check if we have tokens and refresh token + if (tokens == null) { + return CompletableFuture.failedFuture( + new IllegalStateException("No tokens available")); + } + if (!tokens.hasRefreshToken()) { return CompletableFuture.failedFuture( new IllegalStateException("No refresh token available")); } + + // Check if token is actually expired + if (!tokens.isExpired()) { + return CompletableFuture.completedFuture(tokens); + } return CompletableFuture.supplyAsync(() -> { try { + System.out.println("\nRefreshing access token..."); + System.out.println("Current token expired: " + tokens.isExpired()); + System.out.println("Has refresh token: " + tokens.hasRefreshToken()); + System.out.println("Time until expiry: " + tokens.getTimeUntilExpiration() + "ms"); + FormBody.Builder formBuilder = new FormBody.Builder() - .add("app_id", config.getAppId()) .add("grant_type", "refresh_token") .add("refresh_token", tokens.getRefreshToken()) - .add("client_id", config.getClientId()); + .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()) { @@ -206,13 +237,18 @@ public CompletableFuture refreshAccessToken() { formBuilder.add("code_verifier", this.codeVerifier); } - Request request = _getHeaders() + Request request = new Request.Builder() .url(config.getTokenEndpoint()) + .header("Content-Type", "application/x-www-form-urlencoded") .post(formBuilder.build()) .build(); - return executeTokenRequest(request); + OAuthTokens newTokens = executeTokenRequest(request); + System.out.println("Token refresh successful!"); + System.out.println("New token expires in: " + newTokens.getExpiresIn() + " seconds"); + return newTokens; } catch (IOException | RuntimeException e) { + System.err.println("Token refresh failed: " + e.getMessage()); throw new RuntimeException("Failed to refresh tokens", e); } }); @@ -258,8 +294,13 @@ private OAuthTokens executeTokenRequest(Request request) throws IOException { OAuthTokens newTokens = gson.fromJson(body, OAuthTokens.class); - // Keep old refresh token if new one not provided - if (this.tokens != null && newTokens.getRefreshToken() == null) { + // 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()); } @@ -370,17 +411,37 @@ public CompletableFuture revokeOauthAppAuthorization() { } // Convenience methods for token access - public String getAccessToken() { return tokens != null ? tokens.getAccessToken() : null; } - public String getRefreshToken() { return tokens != null ? tokens.getRefreshToken() : null; } - public String getOrganizationUID() { return tokens != null ? tokens.getOrganizationUid() : null; } - public String getUserUID() { return tokens != null ? tokens.getUserUid() : null; } - public Long getTokenExpiryTime() { return tokens != null ? tokens.getExpiresIn() : null; } + public String getAccessToken() { + OAuthTokens t = _getTokens(); + return t != null ? t.getAccessToken() : null; + } + + public String getRefreshToken() { + OAuthTokens t = _getTokens(); + return t != null ? t.getRefreshToken() : null; + } + + public String getOrganizationUID() { + OAuthTokens t = _getTokens(); + return t != null ? t.getOrganizationUid() : null; + } + + public String getUserUID() { + OAuthTokens t = _getTokens(); + return t != null ? t.getUserUid() : null; + } + + public Long getTokenExpiryTime() { + OAuthTokens t = _getTokens(); + return t != null ? t.getExpiresIn() : null; + } /** * Checks if we have a valid access token * @return true if we have a non-expired access token */ public boolean hasValidAccessToken() { - return tokens != null && tokens.hasAccessToken() && !tokens.isExpired(); + OAuthTokens t = _getTokens(); + return t != null && t.hasAccessToken() && !t.isExpired(); } } \ No newline at end of file diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java index 18c4c4d6..1fe1c110 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java @@ -14,8 +14,10 @@ public class OAuthInterceptor implements Interceptor { private static final Logger logger = Logger.getLogger(OAuthInterceptor.class.getName()); + 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; @@ -58,33 +60,97 @@ public Response intercept(Chain chain) throws IOException { .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) { - if (oauthHandler.getTokens().isExpired() && oauthHandler.getTokens().hasRefreshToken()) { - try { - oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS); - } catch (InterruptedException | ExecutionException | TimeoutException e) { - throw new IOException("Failed to refresh access token", e); - } - } - if (oauthHandler.getTokens().hasAccessToken()) { - requestBuilder.header("Authorization", "Bearer " + oauthHandler.getAccessToken()); - System.out.println("Added Authorization header: Bearer " + oauthHandler.getAccessToken()); + if (oauthHandler.getTokens() != null && oauthHandler.getTokens().hasAccessToken()) { + requestBuilder.header("Authorization", "Bearer " + oauthHandler.getAccessToken()); + System.out.println("Added Authorization header: 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("No OAuth tokens available. Please authenticate first."); + } + + // Check if we need to refresh the token before making the request + if (oauthHandler.getTokens().isExpired()) { + + synchronized (refreshLock) { + try { + logger.info("Token expired, refreshing..."); + 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) { + logger.severe("Token refresh failed: " + e.getMessage()); + throw new IOException("Failed to refresh access token", e); } } } - Request request = requestBuilder.build(); + // Execute request Response response = chain.proceed(request); - if (response.code() == 401 && oauthHandler.getTokens() != null && oauthHandler.getTokens().hasRefreshToken()) { - response.close(); + + // Handle error responses + if (!response.isSuccessful() && retryCount < MAX_RETRIES) { + int code = response.code(); + String body = null; try { - oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS); - request = request.newBuilder() - .header("Authorization", "Bearer " + oauthHandler.getAccessToken()) - .build(); - return chain.proceed(request); - } catch (InterruptedException | ExecutionException | TimeoutException e) { - throw new IOException("Failed to refresh access token after 401", e); + if (response.body() != null) { + body = response.body().string(); + } + } catch (IOException e) { + // Ignore body read errors + } + response.close(); + + logger.info("Request failed with code " + code + ": " + body); + + // Handle 401 with token refresh + if (code == 401 && oauthHandler != null && oauthHandler.getTokens() != null && + oauthHandler.getTokens().hasRefreshToken()) { + + synchronized (refreshLock) { + try { + logger.info("Attempting token refresh after 401"); + 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("Failed to refresh access token after 401", e); + } + } + } + + // Handle other retryable errors (429, 5xx) + if ((code == 429 || code >= 500) && code != 501) { + try { + // Exponential backoff + long delay = Math.min(1000 * (1 << retryCount), 30000); + logger.info("Retrying request after " + delay + "ms delay"); + Thread.sleep(delay); + return executeRequest(chain, request, retryCount + 1); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Retry interrupted", e); + } } } From f7ae51f3d40fa0ebcfa83f0d30e9262c80c82bde Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Wed, 3 Sep 2025 16:42:25 +0530 Subject: [PATCH 14/20] chore: Release version 1.8.0 with OAuth 2.0 support and code improvements --- changelog.md | 7 + pom.xml | 2 +- .../contentstack/cms/models/OAuthConfig.java | 32 +- .../contentstack/cms/models/OAuthTokens.java | 74 ++- .../contentstack/cms/oauth/OAuthHandler.java | 206 ++++---- .../cms/oauth/OAuthInterceptor.java | 86 ++-- .../com/contentstack/cms/oauth/OAuthTest.java | 438 ++++++++++++++++++ 7 files changed, 624 insertions(+), 221 deletions(-) create mode 100644 src/test/java/com/contentstack/cms/oauth/OAuthTest.java 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 43ac7c04..f2e0e305 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,7 @@ cms jar contentstack-management-java - 1.7.1-SNAPSHOT + 1.8.0 Contentstack Java Management SDK for Content Management API, Contentstack is a headless CMS with an API-first approach diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index 247f5285..af1b99d9 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -14,6 +14,7 @@ @Getter @Builder public class OAuthConfig { + private final String appId; private final String clientId; private final String clientSecret; @@ -25,7 +26,9 @@ public class OAuthConfig { /** * Validates the configuration - * @throws IllegalArgumentException if required fields are missing or invalid + * + * @throws IllegalArgumentException if required fields are missing or + * invalid */ public void validate() { if (appId == null || appId.trim().isEmpty()) { @@ -47,6 +50,7 @@ public void validate() { /** * Checks if PKCE flow should be used (when clientSecret is not provided) + * * @return true if PKCE should be used */ public boolean isPkceEnabled() { @@ -55,6 +59,7 @@ public boolean isPkceEnabled() { /** * Gets the formatted authorization endpoint URL + * * @return The authorization endpoint URL */ public String getFormattedAuthorizationEndpoint() { @@ -63,19 +68,20 @@ public String getFormattedAuthorizationEndpoint() { } String hostname = "app.contentstack.com"; - + // Transform hostname if needed if (hostname.contains("contentstack")) { hostname = hostname - .replaceAll("^api\\.", "app.") // api.contentstack -> app.contentstack - .replaceAll("\\.io$", ".com"); // *.io -> *.com + .replaceAll("^api\\.", "app.") // api.contentstack -> app.contentstack + .replaceAll("\\.io$", ".com"); // *.io -> *.com } - + return "https://" + hostname + "/#!/apps/" + appId + "/authorize"; } /** * Gets the formatted token endpoint URL + * * @return The token endpoint URL */ public String getTokenEndpoint() { @@ -84,19 +90,20 @@ public String getTokenEndpoint() { } String hostname = "developerhub-api.contentstack.com"; - + // Transform hostname if needed if (hostname.contains("contentstack")) { hostname = hostname - .replaceAll("^dev\\d+\\.", "dev.") // dev1.* -> dev.* - .replaceAll("\\.io$", ".com"); // *.io -> *.com + .replaceAll("^dev\\d+\\.", "dev.") // dev1.* -> dev.* + .replaceAll("\\.io$", ".com"); // *.io -> *.com } - + return "https://" + hostname + "/token"; } /** * Gets the response type, defaulting to "code" + * * @return The response type */ public String getResponseType() { @@ -105,6 +112,7 @@ public String getResponseType() { /** * Gets the scopes as a list + * * @return List of scope strings or empty list if no scopes */ public List getScopesList() { @@ -116,6 +124,7 @@ public List getScopesList() { /** * Gets the scope string + * * @return The space-delimited scope string or null */ public String getScope() { @@ -126,8 +135,10 @@ public String getScope() { * Builder class for OAuthConfig */ public static class OAuthConfigBuilder { + /** * Sets scopes from a list + * * @param scopes List of scope strings * @return Builder instance */ @@ -142,6 +153,7 @@ public OAuthConfigBuilder scopes(List scopes) { /** * Sets scopes from varargs + * * @param scopes Scope strings * @return Builder instance */ @@ -154,4 +166,4 @@ public OAuthConfigBuilder scopes(String... scopes) { return this; } } -} \ No newline at end of file +} diff --git a/src/main/java/com/contentstack/cms/models/OAuthTokens.java b/src/main/java/com/contentstack/cms/models/OAuthTokens.java index d46e07a9..3d8e4115 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthTokens.java +++ b/src/main/java/com/contentstack/cms/models/OAuthTokens.java @@ -14,6 +14,7 @@ @Getter @Setter public class OAuthTokens { + private static final String BEARER_TOKEN_TYPE = "Bearer"; @SerializedName("access_token") private String accessToken; @@ -50,6 +51,7 @@ public OAuthTokens() { /** * Sets the expiration time in seconds and calculates the expiry date + * * @param expiresIn Expiration time in seconds */ public void setExpiresIn(Long expiresIn) { @@ -76,6 +78,7 @@ public synchronized Date getIssuedAt() { /** * Gets the scopes as a list + * * @return List of scope strings or empty list if no scopes */ public List getScopesList() { @@ -87,6 +90,7 @@ public List getScopesList() { /** * Sets scopes from a list + * * @param scopes List of scope strings */ public void setScopesList(List scopes) { @@ -99,6 +103,7 @@ public void setScopesList(List scopes) { /** * Checks if the token has a specific scope + * * @param scopeToCheck The scope to check for * @return true if the token has the scope */ @@ -108,6 +113,7 @@ public boolean hasScope(String scopeToCheck) { /** * Checks if the token is expired, including a buffer time + * * @return true if token is expired or will expire soon */ public boolean isExpired() { @@ -115,32 +121,34 @@ public boolean isExpired() { 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); + return hasAccessToken() && !isExpired() + && BEARER_TOKEN_TYPE.equalsIgnoreCase(tokenType); } /** * Checks if access token is present + * * @return true if access token exists */ public boolean hasAccessToken() { @@ -149,6 +157,7 @@ public boolean hasAccessToken() { /** * Checks if refresh token is present + * * @return true if refresh token exists */ public boolean hasRefreshToken() { @@ -157,6 +166,7 @@ public boolean hasRefreshToken() { /** * Gets time until token expiration in milliseconds + * * @return milliseconds until expiration or 0 if expired/invalid */ public long getTimeUntilExpiration() { @@ -167,46 +177,20 @@ public long getTimeUntilExpiration() { return Math.max(0, timeLeft); } - /** - * Creates a copy of this token object - * @return A new OAuthTokens instance with copied values - */ - public OAuthTokens copy() { - OAuthTokens copy = new OAuthTokens(); - copy.accessToken = this.accessToken; - copy.refreshToken = this.refreshToken; - copy.tokenType = this.tokenType; - copy.expiresIn = this.expiresIn; - copy.scope = this.scope; - copy.organizationUid = this.organizationUid; - copy.userUid = this.userUid; - copy.stackApiKey = this.stackApiKey; - copy.issuedAt = this.issuedAt != null ? new Date(this.issuedAt.getTime()) : null; - copy.expiresAt = this.expiresAt != null ? new Date(this.expiresAt.getTime()) : null; - return copy; - } - - /** - * Gets the stack API key if available - * @return The stack API key or null if not available - */ - public String getStackApiKey() { - return stackApiKey != null && !stackApiKey.trim().isEmpty() ? stackApiKey : null; - } @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 + - '}'; - } -} \ No newline at end of file + 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 index 9cba329d..e969ebbc 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -24,11 +24,12 @@ import okhttp3.ResponseBody; /** - * Handles OAuth 2.0 authentication flow for Contentstack - * Supports both traditional OAuth flow with client secret and PKCE flow + * 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; @@ -37,12 +38,14 @@ public class OAuthHandler { private String codeVerifier; private String codeChallenge; private String state; - - @Getter @Setter + + @Getter + @Setter private OAuthTokens tokens; /** * Creates a new OAuth handler instance + * * @param httpClient HTTP client for making requests * @param config OAuth configuration */ @@ -53,7 +56,7 @@ public OAuthHandler(OkHttpClient httpClient, OAuthConfig config) { // 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(); @@ -61,11 +64,10 @@ public OAuthHandler(OkHttpClient httpClient, OAuthConfig config) { } } - private Request.Builder _getHeaders() { Request.Builder builder = new Request.Builder() - .header("Content-Type", "application/x-www-form-urlencoded"); - + .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()); @@ -75,10 +77,11 @@ private Request.Builder _getHeaders() { /** * 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 int CODE_VERIFIER_LENGTH = 96; final String charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"; SecureRandom random = new SecureRandom(); StringBuilder verifier = new StringBuilder(); @@ -90,6 +93,7 @@ private String generateCodeVerifier() { /** * Generates code challenge from code verifier using SHA-256 + * * @param verifier The code verifier to hash * @return BASE64URL-encoded SHA256 hash of the verifier */ @@ -97,7 +101,7 @@ 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('+', '-') @@ -108,9 +112,9 @@ private String generateCodeChallenge(String verifier) { } } - /** * Starts the OAuth authorization flow + * * @return Authorization URL for the user to visit */ public String authorize() { @@ -119,10 +123,10 @@ public String authorize() { 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")); - + .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")); @@ -134,7 +138,7 @@ public String authorize() { // 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"); + .append("&code_challenge_method=S256"); return urlBuilder.toString(); } } catch (IOException e) { @@ -144,6 +148,7 @@ public String authorize() { /** * Exchanges authorization code for tokens + * * @param code The authorization code from callback * @return Future containing the tokens */ @@ -151,16 +156,14 @@ public CompletableFuture exchangeCodeForToken(String code) { if (code == null || code.trim().isEmpty()) { return CompletableFuture.failedFuture(new IllegalArgumentException("Authorization code cannot be null or empty")); } - - System.out.println("\nExchanging authorization code for tokens..."); 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()); + .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) { @@ -170,9 +173,9 @@ public CompletableFuture exchangeCodeForToken(String code) { } Request request = _getHeaders() - .url(config.getTokenEndpoint()) - .post(formBuilder.build()) - .build(); + .url(config.getTokenEndpoint()) + .post(formBuilder.build()) + .build(); return executeTokenRequest(request); } catch (IOException | RuntimeException e) { @@ -183,6 +186,7 @@ public CompletableFuture exchangeCodeForToken(String code) { /** * Saves tokens from a successful OAuth response + * * @param tokens The tokens to save */ private void _saveTokens(OAuthTokens tokens) { @@ -199,19 +203,20 @@ private OAuthTokens _getTokens() { /** * 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("No tokens available")); + new IllegalStateException("No tokens available")); } if (!tokens.hasRefreshToken()) { return CompletableFuture.failedFuture( - new IllegalStateException("No refresh token available")); + new IllegalStateException("No refresh token available")); } - + // Check if token is actually expired if (!tokens.isExpired()) { return CompletableFuture.completedFuture(tokens); @@ -219,16 +224,12 @@ public CompletableFuture refreshAccessToken() { return CompletableFuture.supplyAsync(() -> { try { - System.out.println("\nRefreshing access token..."); - System.out.println("Current token expired: " + tokens.isExpired()); - System.out.println("Has refresh token: " + tokens.hasRefreshToken()); - System.out.println("Time until expiry: " + tokens.getTimeUntilExpiration() + "ms"); 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("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()) { @@ -238,17 +239,16 @@ public CompletableFuture refreshAccessToken() { } Request request = new Request.Builder() - .url(config.getTokenEndpoint()) - .header("Content-Type", "application/x-www-form-urlencoded") - .post(formBuilder.build()) - .build(); + .url(config.getTokenEndpoint()) + .header("Content-Type", "application/x-www-form-urlencoded") + .post(formBuilder.build()) + .build(); OAuthTokens newTokens = executeTokenRequest(request); - System.out.println("Token refresh successful!"); - System.out.println("New token expires in: " + newTokens.getExpiresIn() + " seconds"); + return newTokens; } catch (IOException | RuntimeException e) { - System.err.println("Token refresh failed: " + e.getMessage()); + throw new RuntimeException("Failed to refresh tokens", e); } }); @@ -258,10 +258,6 @@ public CompletableFuture refreshAccessToken() { * Executes a token request and processes the response */ private OAuthTokens executeTokenRequest(Request request) throws IOException { - System.out.println("\nToken Request Details:"); - System.out.println("URL: " + request.url()); - System.out.println("Method: " + request.method()); - System.out.println("Headers: " + request.headers()); Response response = null; ResponseBody responseBody = null; @@ -269,41 +265,35 @@ private OAuthTokens executeTokenRequest(Request request) throws IOException { response = httpClient.newCall(request).execute(); responseBody = response.body(); - System.out.println("\nToken Response Details:"); - System.out.println("Status Code: " + response.code()); - System.out.println("Headers: " + response.headers()); - if (!response.isSuccessful()) { String error = responseBody != null ? responseBody.string() : "Unknown error"; - System.err.println("Error Response Body: " + 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)); + 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); + throw new RuntimeException("Token request failed with status " + + response.code() + ": " + error); } } String body = responseBody != null ? responseBody.string() : "{}"; - System.out.println("Success Response Body: " + body); - + 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) { @@ -318,17 +308,9 @@ private OAuthTokens executeTokenRequest(Request request) throws IOException { } } - /** - * Handles the OAuth redirect by exchanging the code for tokens - * @param code Authorization code from the redirect - * @return Future containing the tokens - */ - public CompletableFuture handleRedirect(String code) { - return exchangeCodeForToken(code); - } - /** * Logs out the user and optionally revokes authorization + * * @param revokeAuthorization Whether to revoke the app authorization */ public CompletableFuture logout(boolean revokeAuthorization) { @@ -342,15 +324,16 @@ public CompletableFuture logout(boolean revokeAuthorization) { /** * 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(); + .url(config.getFormattedAuthorizationEndpoint() + "/status") + .get() + .build(); Response response = null; ResponseBody responseBody = null; @@ -366,8 +349,12 @@ public CompletableFuture getOauthAppAuthorization() { String body = responseBody != null ? responseBody.string() : "{}"; return gson.fromJson(body, OAuthTokens.class); } finally { - if (responseBody != null) responseBody.close(); - if (response != null) response.close(); + if (responseBody != null) { + responseBody.close(); + } + if (response != null) { + response.close(); + } } } catch (IOException | RuntimeException e) { throw new RuntimeException("Failed to get authorization status", e); @@ -377,18 +364,19 @@ public CompletableFuture getOauthAppAuthorization() { /** * 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(); + .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; @@ -401,8 +389,12 @@ public CompletableFuture revokeOauthAppAuthorization() { throw new RuntimeException("Failed to revoke authorization: " + error); } } finally { - if (responseBody != null) responseBody.close(); - if (response != null) response.close(); + if (responseBody != null) { + responseBody.close(); + } + if (response != null) { + response.close(); + } } } catch (IOException | RuntimeException e) { throw new RuntimeException("Failed to revoke authorization", e); @@ -411,37 +403,33 @@ public CompletableFuture revokeOauthAppAuthorization() { } // Convenience methods for token access - public String getAccessToken() { - OAuthTokens t = _getTokens(); - return t != null ? t.getAccessToken() : null; - } - - public String getRefreshToken() { - OAuthTokens t = _getTokens(); - return t != null ? t.getRefreshToken() : null; + public String getAccessToken() { + OAuthTokens accessToken = _getTokens(); + return accessToken != null ? accessToken.getAccessToken() : null; } - - public String getOrganizationUID() { - OAuthTokens t = _getTokens(); - return t != null ? t.getOrganizationUid() : null; + + public String getRefreshToken() { + OAuthTokens refreshToken = _getTokens(); + return refreshToken != null ? refreshToken.getRefreshToken() : null; } - - public String getUserUID() { - OAuthTokens t = _getTokens(); - return t != null ? t.getUserUid() : null; + + public String getOrganizationUID() { + OAuthTokens organizationUid = _getTokens(); + return organizationUid != null ? organizationUid.getOrganizationUid() : null; } - - public Long getTokenExpiryTime() { - OAuthTokens t = _getTokens(); - return t != null ? t.getExpiresIn() : 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 t = _getTokens(); - return t != null && t.hasAccessToken() && !t.isExpired(); + OAuthTokens accessToken = _getTokens(); + return accessToken != null && accessToken.hasAccessToken() && !accessToken.isExpired(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java index 1fe1c110..df520748 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java @@ -1,19 +1,18 @@ package com.contentstack.cms.oauth; -import com.contentstack.cms.core.Util; -import okhttp3.Interceptor; -import okhttp3.Request; -import okhttp3.Response; - import java.io.IOException; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.logging.Logger; +import com.contentstack.cms.core.Util; + +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; public class OAuthInterceptor implements Interceptor { - private static final Logger logger = Logger.getLogger(OAuthInterceptor.class.getName()); + private static final int MAX_RETRIES = 3; private final OAuthHandler oauthHandler; private String[] earlyAccess; @@ -23,46 +22,33 @@ 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(); + return oauthHandler != null + && oauthHandler.getTokens() != null + && !oauthHandler.getTokens().isExpired(); } @Override public Response intercept(Chain chain) throws IOException { Request originalRequest = chain.request(); - - System.out.println("\nOAuth Interceptor - Request Details:"); - System.out.println("URL: " + originalRequest.url()); - System.out.println("Method: " + originalRequest.method()); - System.out.println("Has Tokens: " + (oauthHandler.getTokens() != null)); - if (oauthHandler.getTokens() != null) { - System.out.println("Access Token: " + oauthHandler.getTokens().getAccessToken()); - System.out.println("Token Expired: " + oauthHandler.getTokens().isExpired()); - } - 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"); + .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()); - System.out.println("Added Authorization header: Bearer " + oauthHandler.getAccessToken()); + } } @@ -83,18 +69,18 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th // Check if we need to refresh the token before making the request if (oauthHandler.getTokens().isExpired()) { - + synchronized (refreshLock) { try { - logger.info("Token expired, refreshing..."); + oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS); - + // Update authorization header with new token request = request.newBuilder() - .header("Authorization", "Bearer " + oauthHandler.getAccessToken()) - .build(); + .header("Authorization", "Bearer " + oauthHandler.getAccessToken()) + .build(); } catch (InterruptedException | ExecutionException | TimeoutException e) { - logger.severe("Token refresh failed: " + e.getMessage()); + throw new IOException("Failed to refresh access token", e); } } @@ -106,45 +92,33 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th // Handle error responses if (!response.isSuccessful() && retryCount < MAX_RETRIES) { int code = response.code(); - String body = null; - try { - if (response.body() != null) { - body = response.body().string(); - } - } catch (IOException e) { - // Ignore body read errors - } response.close(); - - logger.info("Request failed with code " + code + ": " + body); // Handle 401 with token refresh - if (code == 401 && oauthHandler != null && oauthHandler.getTokens() != null && - oauthHandler.getTokens().hasRefreshToken()) { - + if (code == 401 && oauthHandler != null && oauthHandler.getTokens() != null + && oauthHandler.getTokens().hasRefreshToken()) { + synchronized (refreshLock) { try { - logger.info("Attempting token refresh after 401"); + oauthHandler.refreshAccessToken().get(30, TimeUnit.SECONDS); - + // Update authorization header with new token request = request.newBuilder() - .header("Authorization", "Bearer " + oauthHandler.getAccessToken()) - .build(); - + .header("Authorization", "Bearer " + oauthHandler.getAccessToken()) + .build(); + return executeRequest(chain, request, retryCount + 1); } catch (InterruptedException | ExecutionException | TimeoutException e) { throw new IOException("Failed to refresh access token after 401", e); } } } - + // Handle other retryable errors (429, 5xx) if ((code == 429 || code >= 500) && code != 501) { try { - // Exponential backoff long delay = Math.min(1000 * (1 << retryCount), 30000); - logger.info("Retrying request after " + delay + "ms delay"); Thread.sleep(delay); return executeRequest(chain, request, retryCount + 1); } catch (InterruptedException e) { @@ -156,4 +130,4 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th return response; } -} \ No newline at end of file +} 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..0184bc0a --- /dev/null +++ b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java @@ -0,0 +1,438 @@ +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.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() + .setOAuthWithPKCE(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI) + .build(); + + clientSecretClient = new Contentstack.Builder() + .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_CLIENT_SECRET, TEST_REDIRECT_URI) + .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() + .setOAuthWithPKCE("", 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); + } + + // ================= + // 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 ""; + } + } +} From 10383cd1d2e466d90330d19dfe82537ca26d1e4a Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Wed, 3 Sep 2025 16:56:03 +0530 Subject: [PATCH 15/20] refactor: Move OAuth error messages to constants and clean up imports --- .../com/contentstack/cms/Contentstack.java | 23 ++++++++----------- .../java/com/contentstack/cms/core/Util.java | 17 ++++++++++++++ .../contentstack/cms/models/OAuthConfig.java | 10 ++++---- .../contentstack/cms/oauth/OAuthHandler.java | 13 ++++++----- .../cms/oauth/OAuthInterceptor.java | 6 ++--- 5 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 3b6e2e69..043ccb4b 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -5,7 +5,6 @@ import java.time.Duration; import java.util.HashMap; import java.util.Map; -import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; @@ -17,8 +16,6 @@ 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 static com.contentstack.cms.core.Util.ILLEGAL_USER; -import static com.contentstack.cms.core.Util.PLEASE_LOGIN; import com.contentstack.cms.models.Error; import com.contentstack.cms.models.LoginDetails; import com.contentstack.cms.models.OAuthConfig; @@ -99,7 +96,7 @@ public class Contentstack { */ public User user() { if (!isOAuthConfigured() && this.authtoken == null) { - throw new IllegalStateException("Please login or configure OAuth to access user"); + throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " user"); } user = new User(this.instance); return user; @@ -293,7 +290,7 @@ Response logoutWithAuthtoken(String authtoken) throws IOException */ public Organization organization() { if (!isOAuthConfigured() && this.authtoken == null) { - throw new IllegalStateException("Please login or configure OAuth to access organization"); + throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " organization"); } // If using OAuth, get organization from tokens @@ -328,10 +325,10 @@ public Organization organization() { */ public Organization organization(@NotNull String organizationUid) { if (!isOAuthConfigured() && this.authtoken == null) { - throw new IllegalStateException("Please login or configure OAuth to access organization"); + throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " organization"); } if (organizationUid.isEmpty()) { - throw new IllegalStateException("organizationUid can not be empty"); + throw new IllegalStateException(Util.OAUTH_ORG_EMPTY); } return new Organization(this.instance, organizationUid); } @@ -355,7 +352,7 @@ public Organization organization(@NotNull String organizationUid) { */ public Stack stack() { if (!isOAuthConfigured() && this.authtoken == null) { - throw new IllegalStateException("Please login or configure OAuth to access stack"); + throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " stack"); } return new Stack(this.instance); } @@ -380,7 +377,7 @@ public Stack stack() { */ public Stack stack(@NotNull Map header) { if (!isOAuthConfigured() && this.authtoken == null && !header.containsKey(AUTHORIZATION)) { - throw new IllegalStateException("Please login or configure OAuth to access stack"); + throw new IllegalStateException(Util.OAUTH_LOGIN_REQUIRED + " stack"); } return new Stack(this.instance, header); } @@ -476,7 +473,7 @@ public Stack stack(@NotNull String apiKey, @NotNull String managementToken, @Not */ public String getOAuthAuthorizationUrl() { if (!isOAuthConfigured()) { - throw new IllegalStateException("OAuth is not configured. Use Builder.setOAuth() or Builder.setOAuthWithPKCE()"); + throw new IllegalStateException(Util.OAUTH_CONFIG_MISSING); } return oauthHandler.authorize(); } @@ -489,7 +486,7 @@ public String getOAuthAuthorizationUrl() { */ public CompletableFuture exchangeOAuthCode(String code) { if (!isOAuthConfigured()) { - throw new IllegalStateException("OAuth is not configured. Use Builder.setOAuth() or Builder.setOAuthWithPKCE()"); + throw new IllegalStateException(Util.OAUTH_CONFIG_MISSING); } return oauthHandler.exchangeCodeForToken(code); } @@ -501,7 +498,7 @@ public CompletableFuture exchangeOAuthCode(String code) { */ public CompletableFuture refreshOAuthToken() { if (!isOAuthConfigured()) { - throw new IllegalStateException("OAuth is not configured. Use Builder.setOAuth() or Builder.setOAuthWithPKCE()"); + throw new IllegalStateException(Util.OAUTH_CONFIG_MISSING); } return oauthHandler.refreshAccessToken(); } @@ -545,7 +542,7 @@ public OAuthHandler getOAuthHandler() { */ public CompletableFuture oauthLogout(boolean revokeAuthorization) { if (!isOAuthConfigured()) { - throw new IllegalStateException("OAuth is not configured. Use Builder.setOAuth() or Builder.setOAuthWithPKCE()"); + throw new IllegalStateException(Util.OAUTH_CONFIG_MISSING); } return oauthHandler.logout(revokeAuthorization); } diff --git a/src/main/java/com/contentstack/cms/core/Util.java b/src/main/java/com/contentstack/cms/core/Util.java index bd8fe841..11f36939 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() or Builder.setOAuthWithPKCE()"; + 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, diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index af1b99d9..f5360c22 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -8,6 +8,8 @@ import java.util.Arrays; import java.util.List; +import com.contentstack.cms.core.Util; + /** * Configuration class for OAuth 2.0 authentication */ @@ -67,7 +69,7 @@ public String getFormattedAuthorizationEndpoint() { return authEndpoint; } - String hostname = "app.contentstack.com"; + String hostname = Util.OAUTH_APP_HOST; // Transform hostname if needed if (hostname.contains("contentstack")) { @@ -76,7 +78,7 @@ public String getFormattedAuthorizationEndpoint() { .replaceAll("\\.io$", ".com"); // *.io -> *.com } - return "https://" + hostname + "/#!/apps/" + appId + "/authorize"; + return "https://" + hostname + String.format(Util.OAUTH_AUTHORIZE_ENDPOINT, appId); } /** @@ -89,7 +91,7 @@ public String getTokenEndpoint() { return tokenEndpoint; } - String hostname = "developerhub-api.contentstack.com"; + String hostname = Util.OAUTH_API_HOST; // Transform hostname if needed if (hostname.contains("contentstack")) { @@ -98,7 +100,7 @@ public String getTokenEndpoint() { .replaceAll("\\.io$", ".com"); // *.io -> *.com } - return "https://" + hostname + "/token"; + return "https://" + hostname + Util.OAUTH_TOKEN_ENDPOINT; } /** diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index e969ebbc..6e450c2c 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -10,6 +10,7 @@ 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; @@ -154,7 +155,7 @@ public String authorize() { */ public CompletableFuture exchangeCodeForToken(String code) { if (code == null || code.trim().isEmpty()) { - return CompletableFuture.failedFuture(new IllegalArgumentException("Authorization code cannot be null or empty")); + return CompletableFuture.failedFuture(new IllegalArgumentException(Util.OAUTH_EMPTY_CODE)); } return CompletableFuture.supplyAsync(() -> { try { @@ -210,11 +211,11 @@ public CompletableFuture refreshAccessToken() { // Check if we have tokens and refresh token if (tokens == null) { return CompletableFuture.failedFuture( - new IllegalStateException("No tokens available")); + new IllegalStateException(Util.OAUTH_NO_TOKENS)); } if (!tokens.hasRefreshToken()) { return CompletableFuture.failedFuture( - new IllegalStateException("No refresh token available")); + new IllegalStateException(Util.OAUTH_NO_REFRESH_TOKEN)); } // Check if token is actually expired @@ -249,7 +250,7 @@ public CompletableFuture refreshAccessToken() { return newTokens; } catch (IOException | RuntimeException e) { - throw new RuntimeException("Failed to refresh tokens", e); + throw new RuntimeException(Util.OAUTH_REFRESH_FAILED, e); } }); } @@ -343,7 +344,7 @@ public CompletableFuture getOauthAppAuthorization() { if (!response.isSuccessful()) { String error = responseBody != null ? responseBody.string() : "Unknown error"; - throw new RuntimeException("Failed to get authorization status: " + error); + throw new RuntimeException(Util.OAUTH_STATUS_FAILED + ": " + error); } String body = responseBody != null ? responseBody.string() : "{}"; @@ -386,7 +387,7 @@ public CompletableFuture revokeOauthAppAuthorization() { if (!response.isSuccessful()) { String error = responseBody != null ? responseBody.string() : "Unknown error"; - throw new RuntimeException("Failed to revoke authorization: " + error); + throw new RuntimeException(Util.OAUTH_REVOKE_FAILED + ": " + error); } } finally { if (responseBody != null) { diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java index df520748..baf3997b 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthInterceptor.java @@ -64,7 +64,7 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th // Ensure we have tokens if (oauthHandler == null || oauthHandler.getTokens() == null) { - throw new IOException("No OAuth tokens available. Please authenticate first."); + throw new IOException(Util.OAUTH_NO_TOKENS); } // Check if we need to refresh the token before making the request @@ -81,7 +81,7 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th .build(); } catch (InterruptedException | ExecutionException | TimeoutException e) { - throw new IOException("Failed to refresh access token", e); + throw new IOException(Util.OAUTH_REFRESH_FAILED, e); } } } @@ -110,7 +110,7 @@ private Response executeRequest(Chain chain, Request request, int retryCount) th return executeRequest(chain, request, retryCount + 1); } catch (InterruptedException | ExecutionException | TimeoutException e) { - throw new IOException("Failed to refresh access token after 401", e); + throw new IOException(Util.OAUTH_REFRESH_FAILED + " after 401", e); } } } From 96403b1eb0d1672932028a8a21dd1de261f7195f Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 9 Sep 2025 13:45:30 +0530 Subject: [PATCH 16/20] fix: Update OAuth host handling to support for regions --- .../com/contentstack/cms/Contentstack.java | 27 ++++ .../contentstack/cms/models/OAuthConfig.java | 21 ++- .../com/contentstack/cms/oauth/OAuthTest.java | 149 ++++++++++++++++++ 3 files changed, 190 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 043ccb4b..09afff39 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -748,11 +748,25 @@ public Builder setOAuthConfig(OAuthConfig config) { * @return Builder instance */ public Builder setOAuth(String appId, String clientId, String clientSecret, String redirectUri) { + return setOAuth(appId, clientId, clientSecret, redirectUri, this.hostname); + } + + /** + * Configures OAuth with client credentials and specific host + * @param appId Application ID + * @param clientId Client ID + * @param clientSecret Client secret + * @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 clientSecret, String redirectUri, String host) { this.oauthConfig = OAuthConfig.builder() .appId(appId) .clientId(clientId) .clientSecret(clientSecret) .redirectUri(redirectUri) + .host(host) .build(); return this; } @@ -765,10 +779,23 @@ public Builder setOAuth(String appId, String clientId, String clientSecret, Stri * @return Builder instance */ public Builder setOAuthWithPKCE(String appId, String clientId, String redirectUri) { + return setOAuthWithPKCE(appId, clientId, redirectUri, this.hostname); + } + + /** + * Configures OAuth with PKCE (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 setOAuthWithPKCE(String appId, String clientId, String redirectUri, String host) { this.oauthConfig = OAuthConfig.builder() .appId(appId) .clientId(clientId) .redirectUri(redirectUri) + .host(host) .build(); return this; } diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index f5360c22..b13119d9 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -25,6 +25,7 @@ public class OAuthConfig { private final String scope; private final String authEndpoint; private final String tokenEndpoint; + private final String host; /** * Validates the configuration @@ -69,13 +70,16 @@ public String getFormattedAuthorizationEndpoint() { return authEndpoint; } - String hostname = Util.OAUTH_APP_HOST; + String hostname = host != null ? host : Util.OAUTH_APP_HOST; // Transform hostname if needed if (hostname.contains("contentstack")) { hostname = hostname - .replaceAll("^api\\.", "app.") // api.contentstack -> app.contentstack - .replaceAll("\\.io$", ".com"); // *.io -> *.com + .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); @@ -91,13 +95,16 @@ public String getTokenEndpoint() { return tokenEndpoint; } - String hostname = Util.OAUTH_API_HOST; + String hostname = host != null ? host : Util.OAUTH_API_HOST; // Transform hostname if needed if (hostname.contains("contentstack")) { - hostname = hostname - .replaceAll("^dev\\d+\\.", "dev.") // dev1.* -> dev.* - .replaceAll("\\.io$", ".com"); // *.io -> *.com + 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; diff --git a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java index 0184bc0a..245d6db4 100644 --- a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java +++ b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java @@ -14,6 +14,7 @@ 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; @@ -195,6 +196,154 @@ public void testAuthUrlUniqueness() { 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_CLIENT_SECRET, TEST_REDIRECT_URI, testHost) + .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() + .setOAuthWithPKCE(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 // ================= From 75a6b1e267790698ffc0af571c33b9b866bd98e8 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 9 Sep 2025 16:03:59 +0530 Subject: [PATCH 17/20] feat: Implement token storage via TokenCallback interface - Add TokenCallback interface for token storage events - Update OAuthConfig to include tokenCallback - Update OAuthHandler to use callback for token events --- .../contentstack/cms/models/OAuthConfig.java | 6 ++++++ .../contentstack/cms/oauth/OAuthHandler.java | 7 +++++++ .../contentstack/cms/oauth/TokenCallback.java | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 src/main/java/com/contentstack/cms/oauth/TokenCallback.java diff --git a/src/main/java/com/contentstack/cms/models/OAuthConfig.java b/src/main/java/com/contentstack/cms/models/OAuthConfig.java index b13119d9..0b29f02a 100644 --- a/src/main/java/com/contentstack/cms/models/OAuthConfig.java +++ b/src/main/java/com/contentstack/cms/models/OAuthConfig.java @@ -9,6 +9,7 @@ import java.util.List; import com.contentstack.cms.core.Util; +import com.contentstack.cms.oauth.TokenCallback; /** * Configuration class for OAuth 2.0 authentication @@ -27,6 +28,11 @@ public class OAuthConfig { private final String tokenEndpoint; private final String host; + /** + * Callback for token events + */ + private final TokenCallback tokenCallback; + /** * Validates the configuration * diff --git a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java index 6e450c2c..ac79272e 100644 --- a/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java +++ b/src/main/java/com/contentstack/cms/oauth/OAuthHandler.java @@ -193,6 +193,13 @@ public CompletableFuture exchangeCodeForToken(String code) { 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(); + } + } } } 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(); +} From 0c04e34310571a0af4b912109419b4fca4ec040c Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Tue, 9 Sep 2025 17:22:21 +0530 Subject: [PATCH 18/20] fix: Ensure default host is properly used in OAuth config --- .../com/contentstack/cms/Contentstack.java | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index 09afff39..a98c6e73 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -20,6 +20,7 @@ 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; @@ -747,7 +748,20 @@ public Builder setOAuthConfig(OAuthConfig config) { * @param redirectUri Redirect URI * @return Builder instance */ + 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; + } + public Builder setOAuth(String appId, String clientId, String clientSecret, String redirectUri) { + // Use the builder's hostname (which defaults to Util.HOST if not set) return setOAuth(appId, clientId, clientSecret, redirectUri, this.hostname); } @@ -761,13 +775,19 @@ public Builder setOAuth(String appId, String clientId, String clientSecret, Stri * @return Builder instance */ public Builder setOAuth(String appId, String clientId, String clientSecret, String redirectUri, String host) { - this.oauthConfig = OAuthConfig.builder() + OAuthConfig.OAuthConfigBuilder builder = OAuthConfig.builder() .appId(appId) .clientId(clientId) .clientSecret(clientSecret) .redirectUri(redirectUri) - .host(host) - .build(); + .host(host); + + // Add token callback if set + if (this.tokenCallback != null) { + builder.tokenCallback(this.tokenCallback); + } + + this.oauthConfig = builder.build(); return this; } @@ -779,6 +799,7 @@ public Builder setOAuth(String appId, String clientId, String clientSecret, Stri * @return Builder instance */ public Builder setOAuthWithPKCE(String appId, String clientId, String redirectUri) { + // Use the builder's hostname (which defaults to Util.HOST if not set) return setOAuthWithPKCE(appId, clientId, redirectUri, this.hostname); } @@ -791,12 +812,18 @@ public Builder setOAuthWithPKCE(String appId, String clientId, String redirectUr * @return Builder instance */ public Builder setOAuthWithPKCE(String appId, String clientId, String redirectUri, String host) { - this.oauthConfig = OAuthConfig.builder() + OAuthConfig.OAuthConfigBuilder builder = OAuthConfig.builder() .appId(appId) .clientId(clientId) .redirectUri(redirectUri) - .host(host) - .build(); + .host(host); + + // Add token callback if set + if (this.tokenCallback != null) { + builder.tokenCallback(this.tokenCallback); + } + + this.oauthConfig = builder.build(); return this; } From 87317f0415ce7e1e53107fdf9578ca0db64b90db Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Mon, 15 Sep 2025 08:20:56 +0530 Subject: [PATCH 19/20] refactor: Simplify OAuth configuration methods and enhance PKCE flow support - Removed deprecated setOAuthWithPKCE methods and consolidated OAuth configuration into a single setOAuth method. - Updated documentation to clarify clientSecret usage for PKCE flow. - Adjusted error message for missing OAuth configuration to reflect new method usage. --- .../com/contentstack/cms/Contentstack.java | 56 ++++--------------- .../java/com/contentstack/cms/core/Util.java | 2 +- .../com/contentstack/cms/oauth/OAuthTest.java | 6 +- 3 files changed, 16 insertions(+), 48 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index a98c6e73..c42f357a 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -740,14 +740,6 @@ public Builder setOAuthConfig(OAuthConfig config) { return this; } - /** - * Configures OAuth with client credentials (traditional flow) - * @param appId Application ID - * @param clientId Client ID - * @param clientSecret Client secret - * @param redirectUri Redirect URI - * @return Builder instance - */ private TokenCallback tokenCallback; /** @@ -760,64 +752,40 @@ public Builder setTokenCallback(TokenCallback callback) { return this; } - public Builder setOAuth(String appId, String clientId, String clientSecret, String redirectUri) { - // Use the builder's hostname (which defaults to Util.HOST if not set) - return setOAuth(appId, clientId, clientSecret, redirectUri, this.hostname); - } - /** - * Configures OAuth with client credentials and specific host + * Configures OAuth authentication. PKCE flow is automatically used when clientSecret is null or empty. * @param appId Application ID * @param clientId Client ID - * @param clientSecret Client secret + * @param clientSecret Client secret (optional). If null or empty, PKCE flow will be used * @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 clientSecret, String redirectUri, String host) { - OAuthConfig.OAuthConfigBuilder builder = OAuthConfig.builder() - .appId(appId) - .clientId(clientId) - .clientSecret(clientSecret) - .redirectUri(redirectUri) - .host(host); - - // Add token callback if set - if (this.tokenCallback != null) { - builder.tokenCallback(this.tokenCallback); - } - - this.oauthConfig = builder.build(); - return this; - } - - /** - * Configures OAuth with PKCE (no client secret) - * @param appId Application ID - * @param clientId Client ID - * @param redirectUri Redirect URI - * @return Builder instance - */ - public Builder setOAuthWithPKCE(String appId, String clientId, String redirectUri) { + public Builder setOAuth(String appId, String clientId, String clientSecret, String redirectUri) { // Use the builder's hostname (which defaults to Util.HOST if not set) - return setOAuthWithPKCE(appId, clientId, redirectUri, this.hostname); + return setOAuth(appId, clientId, clientSecret, redirectUri, this.hostname); } /** - * Configures OAuth with PKCE (no client secret) and specific host + * Configures OAuth authentication with a specific host. PKCE flow is automatically used when clientSecret is null or empty. * @param appId Application ID * @param clientId Client ID + * @param clientSecret Client secret (optional). If null or empty, PKCE flow will be used * @param redirectUri Redirect URI * @param host API host (e.g. "api.contentstack.io", "eu-api.contentstack.com") * @return Builder instance */ - public Builder setOAuthWithPKCE(String appId, String clientId, String redirectUri, String host) { + public Builder setOAuth(String appId, String clientId, String clientSecret, String redirectUri, String host) { 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); diff --git a/src/main/java/com/contentstack/cms/core/Util.java b/src/main/java/com/contentstack/cms/core/Util.java index 11f36939..a07b302e 100644 --- a/src/main/java/com/contentstack/cms/core/Util.java +++ b/src/main/java/com/contentstack/cms/core/Util.java @@ -61,7 +61,7 @@ public class Util { 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() or Builder.setOAuthWithPKCE()"; + 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"; diff --git a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java index 245d6db4..660c9176 100644 --- a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java +++ b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java @@ -80,7 +80,7 @@ public void setup() { // Create Contentstack clients pkceClient = new Contentstack.Builder() - .setOAuthWithPKCE(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI) + .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, null, TEST_REDIRECT_URI) .build(); clientSecretClient = new Contentstack.Builder() @@ -113,7 +113,7 @@ public void testInvalidConfigurations() { // Test invalid app ID try { new Contentstack.Builder() - .setOAuthWithPKCE("", TEST_CLIENT_ID, TEST_REDIRECT_URI) + .setOAuth("", TEST_CLIENT_ID, null, TEST_REDIRECT_URI) .build(); fail("Should throw exception for empty app ID"); } catch (IllegalArgumentException e) { @@ -291,7 +291,7 @@ public void testHostStorage() { // Test host storage via PKCE builder client = new Contentstack.Builder() - .setOAuthWithPKCE(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI, testHost) + .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, null, TEST_REDIRECT_URI, testHost) .build(); authUrl = client.getOAuthAuthorizationUrl(); From 3d91e99553846b89bec71b3768bf959d07850de4 Mon Sep 17 00:00:00 2001 From: reeshika-h Date: Mon, 15 Sep 2025 08:51:25 +0530 Subject: [PATCH 20/20] refactor: Update OAuth configuration methods to enhance clarity and support for optional client secret - Simplified setOAuth methods by removing the clientSecret parameter from the primary overloads. - Added a new setOAuth method to accommodate an optional client secret while maintaining PKCE flow. - Updated documentation to reflect changes in method signatures and clarify the use of client secret. --- .../com/contentstack/cms/Contentstack.java | 25 +++++++++++++------ .../com/contentstack/cms/oauth/OAuthTest.java | 10 ++++---- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/contentstack/cms/Contentstack.java b/src/main/java/com/contentstack/cms/Contentstack.java index c42f357a..dbf3dbc0 100644 --- a/src/main/java/com/contentstack/cms/Contentstack.java +++ b/src/main/java/com/contentstack/cms/Contentstack.java @@ -753,28 +753,39 @@ public Builder setTokenCallback(TokenCallback callback) { } /** - * Configures OAuth authentication. PKCE flow is automatically used when clientSecret is null or empty. + * Configures OAuth authentication with PKCE flow (no client secret) * @param appId Application ID * @param clientId Client ID - * @param clientSecret Client secret (optional). If null or empty, PKCE flow will be used * @param redirectUri Redirect URI * @return Builder instance */ - public Builder setOAuth(String appId, String clientId, String clientSecret, String redirectUri) { + 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, clientSecret, redirectUri, this.hostname); + return setOAuth(appId, clientId, redirectUri, this.hostname); } /** - * Configures OAuth authentication with a specific host. PKCE flow is automatically used when clientSecret is null or empty. + * Configures OAuth authentication with PKCE flow (no client secret) and specific host * @param appId Application ID * @param clientId Client ID - * @param clientSecret Client secret (optional). If null or empty, PKCE flow will be used * @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 clientSecret, String redirectUri, String host) { + 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) diff --git a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java index 660c9176..3b09c507 100644 --- a/src/test/java/com/contentstack/cms/oauth/OAuthTest.java +++ b/src/test/java/com/contentstack/cms/oauth/OAuthTest.java @@ -80,11 +80,11 @@ public void setup() { // Create Contentstack clients pkceClient = new Contentstack.Builder() - .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, null, TEST_REDIRECT_URI) + .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI) .build(); clientSecretClient = new Contentstack.Builder() - .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_CLIENT_SECRET, TEST_REDIRECT_URI) + .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI, Util.HOST, TEST_CLIENT_SECRET) .build(); } @@ -113,7 +113,7 @@ public void testInvalidConfigurations() { // Test invalid app ID try { new Contentstack.Builder() - .setOAuth("", TEST_CLIENT_ID, null, TEST_REDIRECT_URI) + .setOAuth("", TEST_CLIENT_ID, TEST_REDIRECT_URI) .build(); fail("Should throw exception for empty app ID"); } catch (IllegalArgumentException e) { @@ -282,7 +282,7 @@ public void testHostStorage() { // Test host storage via Contentstack.Builder Contentstack client = new Contentstack.Builder() - .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_CLIENT_SECRET, TEST_REDIRECT_URI, testHost) + .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI, testHost, TEST_CLIENT_SECRET) .build(); String authUrl = client.getOAuthAuthorizationUrl(); @@ -291,7 +291,7 @@ public void testHostStorage() { // Test host storage via PKCE builder client = new Contentstack.Builder() - .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, null, TEST_REDIRECT_URI, testHost) + .setOAuth(TEST_APP_ID, TEST_CLIENT_ID, TEST_REDIRECT_URI, testHost) .build(); authUrl = client.getOAuthAuthorizationUrl();