+ * This client is specifically designed for OAuth operations. + *
+ * Example usage: + *
{@code
+ * OAuthClient client = new OAuthClient.Builder()
+ * .setEndpoint("https://be.contentful.com/")
+ * .build();
+ *
+ * CMAOAuthTokenResponse response = client.oauth().exchangeAuthorizationCode(
+ * "client-id",
+ * "client-secret",
+ * "authorization-code",
+ * "redirect-uri"
+ * );
+ * }
+ */
+public class OAuthClient {
+ private final ModuleOAuth moduleOAuth;
+ private Executor callbackExecutor;
+
+ private OAuthClient(Builder builder) {
+ setCallbackExecutor(builder);
+
+ Retrofit retrofit = new Retrofit.Builder()
+ .baseUrl(builder.endpoint)
+ .callFactory(
+ builder.callFactory == null
+ ? builder.defaultCallFactoryBuilder().build()
+ : builder.callFactory
+ )
+ .addConverterFactory(GsonConverterFactory.create())
+ .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
+ .build();
+
+ this.moduleOAuth = new ModuleOAuth(retrofit, callbackExecutor);
+ }
+
+ private void setCallbackExecutor(Builder builder) {
+ if (builder.callbackExecutor == null) {
+ callbackExecutor = Platform.get().callbackExecutor();
+ } else {
+ callbackExecutor = builder.callbackExecutor;
+ }
+ }
+
+ /**
+ * @return the OAuth module for token operations
+ */
+ public ModuleOAuth oauth() {
+ return moduleOAuth;
+ }
+
+ /**
+ * Builder for creating an OAuthClient instance.
+ */
+ public static class Builder {
+ private String endpoint = "https://be.contentful.com/";
+ private Executor callbackExecutor;
+ private Call.Factory callFactory;
+ private Section application;
+ private Section integration;
+ private Logger logger;
+ private Logger.Level logLevel = NONE;
+
+ /**
+ * Sets the OAuth endpoint URL.
+ *
+ * @param endpoint the OAuth endpoint URL
+ * @return this Builder instance
+ */
+ public Builder setEndpoint(String endpoint) {
+ if (endpoint == null) {
+ throw new IllegalArgumentException("Cannot call setEndpoint() with null.");
+ }
+ this.endpoint = endpoint;
+ return this;
+ }
+
+ /**
+ * Sets the callback executor for async operations.
+ * + * This is optional. If not set, a platform-specific default executor will be used + * (e.g., main thread on Android, or a background thread on JVM). + *
+ * Used for async methods like: + *
{@code
+ * client.oauth().async().exchangeAuthorizationCode(..., callback);
+ * }
+ *
+ * @param executor the executor to run callbacks on
+ * @return this Builder instance
+ */
+ public Builder setCallbackExecutor(Executor executor) {
+ this.callbackExecutor = executor;
+ return this;
+ }
+
+ /**
+ * Sets a custom HTTP call factory.
+ * + * This is optional. If not set, a default call factory with ErrorInterceptor will be used. + *
+ * Use this to customize the HTTP client behavior (e.g., add custom interceptors,
+ * configure timeouts, etc.).
+ *
+ * @param callFactory the custom call factory
+ * @return this Builder instance
+ */
+ public Builder setCallFactory(Call.Factory callFactory) {
+ if (callFactory == null) {
+ throw new IllegalArgumentException("Cannot call setCallFactory() with null.");
+ }
+ this.callFactory = callFactory;
+ return this;
+ }
+
+ /**
+ * Which application is using this client.
+ *
+ * @param name name of the application.
+ * @param version the version of the application.
+ * @return this builder for chaining.
+ */
+ public Builder setApplication(String name, String version) {
+ this.application = Section.app(name, parse(version));
+ return this;
+ }
+
+ /**
+ * Which integration is used.
+ *
+ * @param name name of the integration.
+ * @param version the version of the integration.
+ * @return this builder for chaining.
+ */
+ public Builder setIntegration(String name, String version) {
+ this.integration = Section.integration(name, parse(version));
+ return this;
+ }
+
+ /**
+ * Sets the logger to be used by this client.
+ *
+ * @param logger the logger instance
+ * @return this {@code Builder} instance
+ */
+ public Builder setLogger(Logger logger) {
+ if (logger == null) {
+ throw new IllegalArgumentException("Do not set a null logger");
+ }
+
+ this.logger = logger;
+ return this;
+ }
+
+ /**
+ * Sets the log level for this client.
+ *
+ * @param logLevel {@link Logger.Level} value
+ * @return this {@code Builder} instance
+ */
+ public Builder setLogLevel(Logger.Level logLevel) {
+ if (logLevel == null) {
+ throw new IllegalArgumentException("Cannot call setLogLevel() with null.");
+ }
+ this.logLevel = logLevel;
+ return this;
+ }
+
+ /**
+ * @return default call factory builder, used by the SDK.
+ */
+ public OkHttpClient.Builder defaultCallFactoryBuilder() {
+ final OkHttpClient.Builder okBuilder = new OkHttpClient.Builder()
+ .addInterceptor(new UserAgentHeaderInterceptor(getUserAgent()))
+ .addInterceptor(new ContentfulUserAgentHeaderInterceptor(
+ createCustomHeaderSections(application, integration))
+ )
+ .addInterceptor(new OAuthErrorInterceptor());
+
+ return setLogger(okBuilder);
+ }
+
+ private OkHttpClient.Builder setLogger(OkHttpClient.Builder okBuilder) {
+ if (logger != null) {
+ switch (logLevel) {
+ case NONE:
+ default:
+ break;
+ case BASIC:
+ return okBuilder.addInterceptor(new LogInterceptor(logger));
+ case FULL:
+ return okBuilder.addNetworkInterceptor(new LogInterceptor(logger));
+ }
+ } else {
+ if (logLevel != NONE) {
+ throw new IllegalArgumentException(
+ "Cannot log to a null logger. Please set either no logLevel or "
+ + "set a custom Logger");
+ }
+ }
+ return okBuilder;
+ }
+
+ private String getUserAgent() {
+ return String.format(
+ "contentful-management.java/%s",
+ PROJECT_VERSION);
+ }
+
+ Section[] createCustomHeaderSections(Section application, Section integration) {
+ final Properties properties = System.getProperties();
+
+ return new Section[]{
+ sdk(
+ "contentful-management.java",
+ parse(PROJECT_VERSION)
+ ),
+ platform(
+ "java",
+ parse(properties.getProperty("java.runtime.version"))
+ ),
+ os(
+ OperatingSystem.parse(properties.getProperty("os.name")),
+ Version.parse(properties.getProperty("os.version"))
+ ),
+ application,
+ integration
+ };
+ }
+
+ /**
+ * Builds the OAuthClient instance.
+ *
+ * @return a new OAuthClient instance
+ */
+ public OAuthClient build() {
+ return new OAuthClient(this);
+ }
+ }
+}
diff --git a/src/main/java/com/contentful/java/cma/ServiceOAuth.java b/src/main/java/com/contentful/java/cma/ServiceOAuth.java
new file mode 100644
index 00000000..cb4cfd09
--- /dev/null
+++ b/src/main/java/com/contentful/java/cma/ServiceOAuth.java
@@ -0,0 +1,31 @@
+package com.contentful.java.cma;
+
+import com.contentful.java.cma.model.CMAOAuthTokenResponse;
+
+import io.reactivex.Flowable;
+import retrofit2.http.Field;
+import retrofit2.http.FormUrlEncoded;
+import retrofit2.http.POST;
+
+/**
+ * OAuth Service for token exchange and refresh operations.
+ */
+public interface ServiceOAuth {
+ @FormUrlEncoded
+ @POST("oauth/token")
+ Flowable
+ * OAuth error responses follow the OAuth 2.0 specification format:
+ * {@code
+ * {
+ * "error": "invalid_grant",
+ * "errorMessage": "The provided authorization grant is invalid...",
+ * "requestId": "1b991683-6531-4a22-a765-1a9c6781c1ee"
+ * }
+ * }
+ */
+public class OAuthException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * OAuth error body structure.
+ */
+ public static class ErrorBody {
+ String error;
+ String errorMessage;
+ String requestId;
+
+ /**
+ * @return the OAuth error code (e.g., "invalid_grant", "invalid_client")
+ */
+ public String getError() {
+ return error;
+ }
+
+ /**
+ * @return the human-readable error message
+ */
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+
+ /**
+ * @return the request ID for tracking purposes
+ */
+ public String getRequestId() {
+ return requestId;
+ }
+
+ @Override
+ public String toString() {
+ return "ErrorBody { "
+ + (getError() != null ? "error = " + getError() + ", " : "")
+ + (getErrorMessage() != null ? "errorMessage = " + getErrorMessage() + ", " : "")
+ + (getRequestId() != null ? "requestId = " + getRequestId() + " " : "")
+ + "}";
+ }
+ }
+
+ private final Request request;
+ private final Response response;
+ private ErrorBody errorBody;
+
+ /**
+ * Construct an OAuth error exception.
+ *
+ * @param request the request that caused the error
+ * @param response the error response from the OAuth server
+ */
+ public OAuthException(Request request, Response response) {
+ this.request = request;
+ this.response = response;
+
+ try {
+ final String body = response.body() != null ? response.body().string() : null;
+ this.errorBody = new GsonBuilder().create().fromJson(body, ErrorBody.class);
+ } catch (IOException e) {
+ this.errorBody = null;
+ }
+ }
+
+ /**
+ * @return the HTTP response code
+ */
+ public int responseCode() {
+ return response.code();
+ }
+
+ /**
+ * @return the HTTP response message
+ */
+ public String responseMessage() {
+ return response.message();
+ }
+
+ /**
+ * @return the parsed error body, or null if parsing failed
+ */
+ public ErrorBody getErrorBody() {
+ return errorBody;
+ }
+
+ @Override
+ public String toString() {
+ if (errorBody == null) {
+ return format(
+ Locale.getDefault(),
+ "OAuth FAILED \n\t%s\n\t↳ Header{%s}%s\n\t%s\n\t↳ Header{%s}",
+ request.toString(),
+ headersToString(request.headers()),
+ maybeBodyToString(request.body()),
+ response.toString(),
+ headersToString(response.headers()));
+ } else {
+ return format(
+ Locale.getDefault(),
+ "OAuth FAILED %s\n\t%s\n\t↳ Header{%s}%s\n\t%s\n\t↳ Header{%s}",
+ errorBody.toString(),
+ request.toString(),
+ headersToString(request.headers()),
+ maybeBodyToString(request.body()),
+ response.toString(),
+ headersToString(response.headers()));
+ }
+ }
+
+ private String maybeBodyToString(RequestBody body) {
+ if (body != null) {
+ final Buffer sink = new Buffer();
+ try {
+ body.writeTo(sink);
+ final String bodyContent = sink.readString(Charset.defaultCharset());
+ return "\n\t↳ Body " + bodyContent;
+ } catch (IOException e) {
+ return "";
+ }
+ } else {
+ return "";
+ }
+ }
+
+ private String headersToString(Headers headers) {
+ final StringBuilder builder = new StringBuilder();
+
+ String divider = "";
+ for (final String name : headers.names()) {
+ final String value = headers.get(name);
+ builder.append(divider);
+ builder.append(name);
+ builder.append(": ");
+ builder.append(value);
+
+ if (divider.isEmpty()) {
+ divider = ", ";
+ }
+ }
+
+ return builder.toString();
+ }
+}
diff --git a/src/test/kotlin/com/contentful/java/cma/OAuthTests.kt b/src/test/kotlin/com/contentful/java/cma/OAuthTests.kt
new file mode 100644
index 00000000..59d52c17
--- /dev/null
+++ b/src/test/kotlin/com/contentful/java/cma/OAuthTests.kt
@@ -0,0 +1,166 @@
+package com.contentful.java.cma
+
+import com.contentful.java.cma.lib.TestUtils
+import com.contentful.java.cma.model.OAuthException
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Before
+import java.util.logging.LogManager
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+import org.junit.Test as test
+
+class OAuthTests {
+ lateinit var server: MockWebServer
+ lateinit var client: OAuthClient
+
+ @Before
+ fun setUp() {
+ LogManager.getLogManager().reset()
+ // MockWebServer
+ server = MockWebServer()
+ server.start()
+
+ // OAuth client - no access token required
+ client = OAuthClient.Builder()
+ .setEndpoint(server.url("/").toString())
+ .build()
+ }
+
+ @After
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ @test
+ fun testExchangeAuthorizationCode() {
+ val responseBody = TestUtils.fileToString("oauth_token_exchange_response.json")
+ server.enqueue(MockResponse().setResponseCode(200).setBody(responseBody))
+
+ val result = client.oauth().exchangeAuthorizationCode(
+ "test-client-id",
+ "test-client-secret",
+ "test-authorization-code",
+ "https://example.com/callback"
+ )
+
+ // Verify response
+ assertNotNull(result)
+ assertEquals("test-access-token", result.accessToken)
+ assertEquals("test-refresh-token", result.refreshToken)
+ assertEquals("Bearer", result.tokenType)
+ assertEquals(2591999, result.expiresIn)
+ assertEquals("content_management_manage", result.scope)
+ assertEquals(1737558891L, result.createdAt)
+
+ // Verify request
+ val recordedRequest = server.takeRequest()
+ assertEquals("POST", recordedRequest.method)
+ assertEquals("/oauth/token", recordedRequest.path)
+ assertEquals("application/x-www-form-urlencoded", recordedRequest.getHeader("Content-Type"))
+
+ val requestBody = recordedRequest.body.readUtf8()
+ assert(requestBody.contains("grant_type=authorization_code"))
+ assert(requestBody.contains("client_id=test-client-id"))
+ assert(requestBody.contains("client_secret=test-client-secret"))
+ assert(requestBody.contains("code=test-authorization-code"))
+ assert(requestBody.contains("redirect_uri=https%3A%2F%2Fexample.com%2Fcallback"))
+ }
+
+ @test
+ fun testRefreshAccessToken() {
+ val responseBody = TestUtils.fileToString("oauth_token_refresh_response.json")
+ server.enqueue(MockResponse().setResponseCode(200).setBody(responseBody))
+
+ val result = client.oauth().refreshAccessToken(
+ "test-client-id",
+ "test-client-secret",
+ "old-refresh-token",
+ "https://example.com/callback"
+ )
+
+ // Verify response
+ assertNotNull(result)
+ assertEquals("new-access-token", result.accessToken)
+ assertEquals("new-refresh-token", result.refreshToken)
+ assertEquals("Bearer", result.tokenType)
+ assertEquals(2591999, result.expiresIn)
+ assertEquals("content_management_manage", result.scope)
+ assertEquals(1737558900L, result.createdAt)
+
+ // Verify request
+ val recordedRequest = server.takeRequest()
+ assertEquals("POST", recordedRequest.method)
+ assertEquals("/oauth/token", recordedRequest.path)
+ assertEquals("application/x-www-form-urlencoded", recordedRequest.getHeader("Content-Type"))
+
+ val requestBody = recordedRequest.body.readUtf8()
+ assert(requestBody.contains("grant_type=refresh_token"))
+ assert(requestBody.contains("client_id=test-client-id"))
+ assert(requestBody.contains("client_secret=test-client-secret"))
+ assert(requestBody.contains("refresh_token=old-refresh-token"))
+ assert(requestBody.contains("redirect_uri=https%3A%2F%2Fexample.com%2Fcallback"))
+ }
+
+ @test
+ fun testExchangeAuthorizationCodeWitError() {
+ val errorResponse = """
+ {
+ "error": "invalid_grant",
+ "errorMessage": "The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.",
+ "requestId": "1b991683-6531-4a22-a765-1a9c6781c1ee"
+ }
+ """.trimIndent()
+ server.enqueue(MockResponse().setResponseCode(400).setBody(errorResponse))
+
+ try {
+ client.oauth().exchangeAuthorizationCode(
+ "invalid-client-id",
+ "invalid-secret",
+ "test-code",
+ "https://example.com/callback"
+ )
+ assert(false) { "Should have thrown OAuthException for 400" }
+ } catch (e: OAuthException) {
+ // Expected - verify it's a proper OAuth error
+ assertNotNull(e)
+ assertEquals(400, e.responseCode())
+ assertNotNull(e.errorBody)
+ assertEquals("invalid_grant", e.errorBody?.error)
+ assertTrue(e.errorBody?.errorMessage?.contains("authorization grant is invalid") == true)
+ assertEquals("1b991683-6531-4a22-a765-1a9c6781c1ee", e.errorBody?.requestId)
+ }
+ }
+
+ @test
+ fun testRefreshTokenWithError() {
+ val errorResponse = """
+ {
+ "error": "invalid_grant",
+ "errorMessage": "The refresh token is invalid or expired.",
+ "requestId": "2c882794-7642-5b33-b876-2b0d7892d2ff"
+ }
+ """.trimIndent()
+ server.enqueue(MockResponse().setResponseCode(400).setBody(errorResponse))
+
+ try {
+ client.oauth().refreshAccessToken(
+ "test-client-id",
+ "test-secret",
+ "expired-refresh-token",
+ "https://example.com/callback"
+ )
+ assert(false) { "Should have thrown OAuthException for 400" }
+ } catch (e: OAuthException) {
+ // Expected - verify it's a proper OAuth error
+ assertNotNull(e)
+ assertEquals(400, e.responseCode())
+ assertNotNull(e.errorBody)
+ assertEquals("invalid_grant", e.errorBody?.error)
+ assertTrue(e.errorBody?.errorMessage?.contains("refresh token is invalid") == true)
+ assertEquals("2c882794-7642-5b33-b876-2b0d7892d2ff", e.errorBody?.requestId)
+ }
+ }
+}
diff --git a/src/test/resources/oauth_token_exchange_response.json b/src/test/resources/oauth_token_exchange_response.json
new file mode 100644
index 00000000..3a1c7c54
--- /dev/null
+++ b/src/test/resources/oauth_token_exchange_response.json
@@ -0,0 +1,8 @@
+{
+ "access_token": "test-access-token",
+ "refresh_token": "test-refresh-token",
+ "token_type": "Bearer",
+ "expires_in": 2591999,
+ "scope": "content_management_manage",
+ "created_at": 1737558891
+}
diff --git a/src/test/resources/oauth_token_refresh_response.json b/src/test/resources/oauth_token_refresh_response.json
new file mode 100644
index 00000000..d950930d
--- /dev/null
+++ b/src/test/resources/oauth_token_refresh_response.json
@@ -0,0 +1,8 @@
+{
+ "access_token": "new-access-token",
+ "refresh_token": "new-refresh-token",
+ "token_type": "Bearer",
+ "expires_in": 2591999,
+ "scope": "content_management_manage",
+ "created_at": 1737558900
+}