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