diff --git a/pom.xml b/pom.xml index 00e3b327..ead7869c 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ com.contentful.java cma-sdk - 3.4.3.13-SMT-SNAPSHOT + 3.4.3.13-1-SMT-SNAPSHOT jar cma-sdk diff --git a/src/main/java/com/contentful/java/cma/ModuleOAuth.java b/src/main/java/com/contentful/java/cma/ModuleOAuth.java new file mode 100644 index 00000000..449a46a1 --- /dev/null +++ b/src/main/java/com/contentful/java/cma/ModuleOAuth.java @@ -0,0 +1,125 @@ +package com.contentful.java.cma; + +import com.contentful.java.cma.model.CMAOAuthTokenResponse; + +import java.util.concurrent.Executor; + +import retrofit2.Retrofit; + +/** + * OAuth Module for token exchange and refresh operations. + */ +public class ModuleOAuth extends AbsModule { + final Async async; + + /** + * Create the OAuth module. + * + * @param retrofit the retrofit instance to be used to create the service. + * @param callbackExecutor to tell on which thread it should run. + */ + public ModuleOAuth(Retrofit retrofit, Executor callbackExecutor) { + super(retrofit, callbackExecutor, null, null, false); + this.async = new Async(); + } + + @Override + protected ServiceOAuth createService(Retrofit retrofit) { + return retrofit.create(ServiceOAuth.class); + } + + /** + * Exchange authorization code for access and refresh tokens. + * + * @param clientId the OAuth client ID. + * @param clientSecret the OAuth client secret. + * @param authorizationCode the authorization code received from the OAuth flow. + * @param redirectUri the redirect URI used in the OAuth flow. + * @return {@link CMAOAuthTokenResponse} result instance containing access and + * refresh tokens. + * @throws IllegalArgumentException if any parameter is null. + */ + public CMAOAuthTokenResponse exchangeAuthorizationCode(String clientId, + String clientSecret, + String authorizationCode, + String redirectUri) { + assertNotNull(clientId, "clientId"); + assertNotNull(clientSecret, "clientSecret"); + assertNotNull(authorizationCode, "authorizationCode"); + assertNotNull(redirectUri, "redirectUri"); + + return service.exchangeAuthorizationCode("authorization_code", clientId, + clientSecret, authorizationCode, redirectUri).blockingFirst(); + } + + /** + * Refresh access token using refresh token. + * + * @param clientId the OAuth client ID. + * @param clientSecret the OAuth client secret. + * @param refreshToken the refresh token. + * @param redirectUri the redirect URI used in the OAuth flow. + * @return {@link CMAOAuthTokenResponse} result instance containing new access and + * refresh tokens. + * @throws IllegalArgumentException if any parameter is null. + */ + public CMAOAuthTokenResponse refreshAccessToken(String clientId, String clientSecret, + String refreshToken, String redirectUri) { + assertNotNull(clientId, "clientId"); + assertNotNull(clientSecret, "clientSecret"); + assertNotNull(refreshToken, "refreshToken"); + assertNotNull(redirectUri, "redirectUri"); + + return service.refreshAccessToken("refresh_token", clientId, clientSecret, + refreshToken, redirectUri).blockingFirst(); + } + + /** + * Async methods for OAuth operations. + */ + public class Async { + /** + * Exchange authorization code for access and refresh tokens asynchronously. + * + * @param clientId the OAuth client ID. + * @param clientSecret the OAuth client secret. + * @param authorizationCode the authorization code received from the OAuth flow. + * @param redirectUri the redirect URI used in the OAuth flow. + * @param callback callback to be called on success or error. + * @return the callback passed in. + */ + public CMACallback exchangeAuthorizationCode( + String clientId, String clientSecret, String authorizationCode, + String redirectUri, CMACallback callback) { + return defer(new RxExtensions.DefFunc() { + @Override + CMAOAuthTokenResponse method() { + return ModuleOAuth.this.exchangeAuthorizationCode(clientId, + clientSecret, authorizationCode, redirectUri); + } + }, callback); + } + + /** + * Refresh access token using refresh token asynchronously. + * + * @param clientId the OAuth client ID. + * @param clientSecret the OAuth client secret. + * @param refreshToken the refresh token. + * @param redirectUri the redirect URI used in the OAuth flow. + * @param callback callback to be called on success or error. + * @return the callback passed in. + */ + public CMACallback refreshAccessToken(String clientId, + String clientSecret, String refreshToken, String redirectUri, + CMACallback callback) { + return defer(new RxExtensions.DefFunc() { + @Override + CMAOAuthTokenResponse method() { + return ModuleOAuth.this.refreshAccessToken(clientId, clientSecret, + refreshToken, redirectUri); + } + }, callback); + } + } +} diff --git a/src/main/java/com/contentful/java/cma/OAuthClient.java b/src/main/java/com/contentful/java/cma/OAuthClient.java new file mode 100644 index 00000000..53e803db --- /dev/null +++ b/src/main/java/com/contentful/java/cma/OAuthClient.java @@ -0,0 +1,269 @@ +package com.contentful.java.cma; + +import com.contentful.java.cma.interceptor.ContentfulUserAgentHeaderInterceptor; +import com.contentful.java.cma.interceptor.ContentfulUserAgentHeaderInterceptor.Section; +import com.contentful.java.cma.interceptor.ContentfulUserAgentHeaderInterceptor.Section.OperatingSystem; +import com.contentful.java.cma.interceptor.ContentfulUserAgentHeaderInterceptor.Section.Version; +import com.contentful.java.cma.interceptor.LogInterceptor; +import com.contentful.java.cma.interceptor.OAuthErrorInterceptor; +import com.contentful.java.cma.interceptor.UserAgentHeaderInterceptor; +import okhttp3.Call; +import okhttp3.OkHttpClient; +import retrofit2.Retrofit; +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; +import retrofit2.converter.gson.GsonConverterFactory; + +import java.util.Properties; +import java.util.concurrent.Executor; + +import static com.contentful.java.cma.Logger.Level.NONE; +import static com.contentful.java.cma.build.GeneratedBuildParameters.PROJECT_VERSION; +import static com.contentful.java.cma.interceptor.ContentfulUserAgentHeaderInterceptor.Section.Version.parse; +import static com.contentful.java.cma.interceptor.ContentfulUserAgentHeaderInterceptor.Section.os; +import static com.contentful.java.cma.interceptor.ContentfulUserAgentHeaderInterceptor.Section.platform; +import static com.contentful.java.cma.interceptor.ContentfulUserAgentHeaderInterceptor.Section.sdk; + +/** + * OAuth client for Contentful Management API. + *

+ * 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 exchangeAuthorizationCode( + @Field("grant_type") String grantType, + @Field("client_id") String clientId, + @Field("client_secret") String clientSecret, + @Field("code") String authorizationCode, + @Field("redirect_uri") String redirectUri); + + @FormUrlEncoded + @POST("oauth/token") + Flowable refreshAccessToken( + @Field("grant_type") String grantType, + @Field("client_id") String clientId, + @Field("client_secret") String clientSecret, + @Field("refresh_token") String refreshToken, + @Field("redirect_uri") String redirectUri); +} diff --git a/src/main/java/com/contentful/java/cma/interceptor/OAuthErrorInterceptor.java b/src/main/java/com/contentful/java/cma/interceptor/OAuthErrorInterceptor.java new file mode 100644 index 00000000..9bf9605c --- /dev/null +++ b/src/main/java/com/contentful/java/cma/interceptor/OAuthErrorInterceptor.java @@ -0,0 +1,34 @@ +package com.contentful.java.cma.interceptor; + +import com.contentful.java.cma.model.OAuthException; + +import java.io.IOException; + +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +/** + * Interceptor for OAuth operations that throws OAuthException on error responses. + */ +public class OAuthErrorInterceptor implements Interceptor { + + /** + * Intercepts chain to check for unsuccessful OAuth requests. + * + * @param chain provided by the framework to check + * @return the response if no error occurred + * @throws IOException will get thrown if response code is unsuccessful + */ + @Override + public Response intercept(Chain chain) throws IOException { + final Request request = chain.request(); + final Response response = chain.proceed(request); + + if (!response.isSuccessful()) { + throw new OAuthException(request, response); + } + + return response; + } +} diff --git a/src/main/java/com/contentful/java/cma/model/CMAOAuthTokenResponse.java b/src/main/java/com/contentful/java/cma/model/CMAOAuthTokenResponse.java new file mode 100644 index 00000000..382a45a6 --- /dev/null +++ b/src/main/java/com/contentful/java/cma/model/CMAOAuthTokenResponse.java @@ -0,0 +1,77 @@ +package com.contentful.java.cma.model; + +import com.google.gson.annotations.SerializedName; + +public class CMAOAuthTokenResponse { + @SerializedName("access_token") + private String accessToken; + + @SerializedName("refresh_token") + private String refreshToken; + + @SerializedName("token_type") + private String tokenType; + + @SerializedName("expires_in") + private Integer expiresIn; + + @SerializedName("scope") + private String scope; + + @SerializedName("created_at") + private Long createdAt; + + public String getAccessToken() { + return accessToken; + } + + public CMAOAuthTokenResponse setAccessToken(String accessToken) { + this.accessToken = accessToken; + return this; + } + + public String getRefreshToken() { + return refreshToken; + } + + public CMAOAuthTokenResponse setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + return this; + } + + public String getTokenType() { + return tokenType; + } + + public CMAOAuthTokenResponse setTokenType(String tokenType) { + this.tokenType = tokenType; + return this; + } + + public Integer getExpiresIn() { + return expiresIn; + } + + public CMAOAuthTokenResponse setExpiresIn(Integer expiresIn) { + this.expiresIn = expiresIn; + return this; + } + + public String getScope() { + return scope; + } + + public CMAOAuthTokenResponse setScope(String scope) { + this.scope = scope; + return this; + } + + public Long getCreatedAt() { + return createdAt; + } + + public CMAOAuthTokenResponse setCreatedAt(Long createdAt) { + this.createdAt = createdAt; + return this; + } +} diff --git a/src/main/java/com/contentful/java/cma/model/OAuthException.java b/src/main/java/com/contentful/java/cma/model/OAuthException.java new file mode 100644 index 00000000..1d0b4540 --- /dev/null +++ b/src/main/java/com/contentful/java/cma/model/OAuthException.java @@ -0,0 +1,171 @@ +package com.contentful.java.cma.model; + +import com.google.gson.GsonBuilder; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.util.Locale; + +import okhttp3.Headers; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okio.Buffer; + +import static java.lang.String.format; + +/** + * Exception thrown when OAuth token operations fail. + *

+ * 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 +}