From c107031c92fca37e8f52e58fb2d4f4ff2a898ce6 Mon Sep 17 00:00:00 2001 From: "Kipchumba C. Bett" Date: Wed, 10 Jul 2024 09:11:57 +0300 Subject: [PATCH] OZ-529: Add support for OAuth2 authentication --- camel-frappe-api/pom.xml | 6 + .../security/oauth2/OAuth2Config.java | 21 ++++ .../security/oauth2/OAuth2Interceptor.java | 31 +++++ .../internal/security/oauth2/OAuth2Token.java | 27 ++++ .../security/oauth2/OAuth2TokenManager.java | 60 +++++++++ .../oauth2/OAuth2InterceptorTest.java | 79 ++++++++++++ .../oauth2/OAuth2TokenManagerTest.java | 116 ++++++++++++++++++ .../security/oauth2/OAuth2TokenTest.java | 51 ++++++++ .../resources/com/ozonehis/camel/frappe.json | 2 +- 9 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Config.java create mode 100644 camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Interceptor.java create mode 100644 camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Token.java create mode 100644 camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenManager.java create mode 100644 camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2InterceptorTest.java create mode 100644 camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenManagerTest.java create mode 100644 camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenTest.java diff --git a/camel-frappe-api/pom.xml b/camel-frappe-api/pom.xml index 155052a..b6ab2c1 100644 --- a/camel-frappe-api/pom.xml +++ b/camel-frappe-api/pom.xml @@ -46,6 +46,12 @@ 5.8.0 test + + com.squareup.okhttp3 + mockwebserver + ${okhttp.version} + test + diff --git a/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Config.java b/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Config.java new file mode 100644 index 0000000..6b25ca5 --- /dev/null +++ b/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Config.java @@ -0,0 +1,21 @@ +package com.ozonehis.camel.frappe.sdk.internal.security.oauth2; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@NoArgsConstructor +@AllArgsConstructor +@Setter +@Getter +public class OAuth2Config { + + private String clientId; + + private String clientSecret; + + private String oauthTokenUri; + + private String[] scopes; +} diff --git a/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Interceptor.java b/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Interceptor.java new file mode 100644 index 0000000..0d84316 --- /dev/null +++ b/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Interceptor.java @@ -0,0 +1,31 @@ +package com.ozonehis.camel.frappe.sdk.internal.security.oauth2; + +import com.ozonehis.camel.frappe.sdk.api.security.FrappeAuthentication; +import java.io.IOException; +import lombok.Setter; +import okhttp3.Request; +import okhttp3.Response; + +@Setter +public class OAuth2Interceptor implements FrappeAuthentication { + + private OAuth2Config config; + + private OAuth2TokenManager tokenManager; + + public OAuth2Interceptor(OAuth2Config oAuth2Config) { + this.config = oAuth2Config; + this.tokenManager = new OAuth2TokenManager(config); + } + + @Override + public Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + String accessToken = this.tokenManager.getAccessToken(); + Request modifiedRequest = originalRequest + .newBuilder() + .header("Authorization", "Bearer " + accessToken) + .build(); + return chain.proceed(modifiedRequest); + } +} diff --git a/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Token.java b/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Token.java new file mode 100644 index 0000000..9727ab0 --- /dev/null +++ b/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Token.java @@ -0,0 +1,27 @@ +package com.ozonehis.camel.frappe.sdk.internal.security.oauth2; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OAuth2Token { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in") + private String expiresIn; + + @JsonProperty("token_type") + private String tokenType; +} diff --git a/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenManager.java b/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenManager.java new file mode 100644 index 0000000..26e0bfc --- /dev/null +++ b/camel-frappe-api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenManager.java @@ -0,0 +1,60 @@ +package com.ozonehis.camel.frappe.sdk.internal.security.oauth2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import javax.security.sasl.AuthenticationException; +import lombok.Setter; +import okhttp3.FormBody; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +public class OAuth2TokenManager { + + private final OAuth2Config config; + + private String accessToken; + + private long expiryTime = 0; + + @Setter + private OkHttpClient client; + + public OAuth2TokenManager(OAuth2Config config) { + this.config = config; + this.client = new OkHttpClient(); + } + + public synchronized String getAccessToken() throws IOException { + if (System.currentTimeMillis() >= expiryTime) { + refreshAccessToken(); + } + return accessToken; + } + + private void refreshAccessToken() throws IOException { + FormBody formBody = new FormBody.Builder() + .add("grant_type", "client_credentials") + .add("client_id", config.getClientId()) + .add("client_secret", config.getClientSecret()) + .add("scope", String.join(" ", config.getScopes())) + .build(); + Request request = new Request.Builder() + .url(config.getOauthTokenUri()) + .post(formBody) + .build(); + + try (Response response = client.newCall(request).execute()) { + if (response.isSuccessful() && response.body() != null) { + OAuth2Token token = new ObjectMapper().readValue(response.body().string(), OAuth2Token.class); + long expiresIn = Long.parseLong(token.getExpiresIn()); + this.accessToken = token.getAccessToken(); + this.expiryTime = System.currentTimeMillis() + + (expiresIn * 1000) + - 5000; // Subtract 5 seconds to ensure we refresh before expiry + } else { + throw new AuthenticationException("Failed to fetch access token with status code: " + response.code()); + } + } + } +} diff --git a/camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2InterceptorTest.java b/camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2InterceptorTest.java new file mode 100644 index 0000000..321f600 --- /dev/null +++ b/camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2InterceptorTest.java @@ -0,0 +1,79 @@ +package com.ozonehis.camel.frappe.sdk.internal.security.oauth2; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.openMocks; + +import java.io.IOException; +import okhttp3.Interceptor.Chain; +import okhttp3.Request; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +class OAuth2InterceptorTest { + + @Mock + private OAuth2Config mockConfig; + + @Mock + private OAuth2TokenManager mockTokenManager; + + @Mock + private Chain mockChain; + + private OAuth2Interceptor interceptor; + + private static AutoCloseable mocksCloser; + + @BeforeEach + void setUp() { + mocksCloser = openMocks(this); + + when(mockConfig.getClientId()).thenReturn("testClientId"); + when(mockConfig.getClientSecret()).thenReturn("testClientSecret"); + when(mockConfig.getScopes()).thenReturn(new String[] {"testScope"}); + when(mockConfig.getOauthTokenUri()).thenReturn("http://test.com/token"); + + interceptor = new OAuth2Interceptor(mockConfig); + interceptor.setTokenManager(mockTokenManager); + } + + @AfterAll + static void closeMocks() throws Exception { + mocksCloser.close(); + } + + @Test + @DisplayName("Should add Authorization header") + void shouldAddAuthorizationHeader() throws Exception { + // Arrange + when(mockTokenManager.getAccessToken()).thenReturn("testAccessToken"); + Request originalRequest = new Request.Builder().url("http://test.com").build(); + when(mockChain.request()).thenReturn(originalRequest); + + // Act + interceptor.intercept(mockChain); + + // Verify + verify(mockChain, times(1)) + .proceed(argThat(request -> "Bearer testAccessToken".equals(request.header("Authorization")))); + } + + @Test + @DisplayName("Should handle TokenManagerException") + void shouldHandleTokenManagerException() throws Exception { + // Arrange + when(mockTokenManager.getAccessToken()).thenThrow(new IOException("Failed to get access token")); + Request originalRequest = new Request.Builder().url("http://test.com").build(); + when(mockChain.request()).thenReturn(originalRequest); + + // Act & Verify + assertThrows(IOException.class, () -> interceptor.intercept(mockChain)); + } +} diff --git a/camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenManagerTest.java b/camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenManagerTest.java new file mode 100644 index 0000000..70fe908 --- /dev/null +++ b/camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenManagerTest.java @@ -0,0 +1,116 @@ +package com.ozonehis.camel.frappe.sdk.internal.security.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.openMocks; + +import java.io.IOException; +import javax.security.sasl.AuthenticationException; +import okhttp3.Call; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +class OAuth2TokenManagerTest { + + @Mock + private OkHttpClient mockClient; + + @Mock + private Call mockCall; + + @Mock + private Response mockResponse; + + @Mock + private ResponseBody mockResponseBody; + + private OAuth2TokenManager tokenManager; + + private static AutoCloseable mocksCloser; + + @BeforeEach + void setUp() throws IOException { + mocksCloser = openMocks(this); + OAuth2Config config = new OAuth2Config( + "clientId", "clientSecret", "http://oauth.token.uri", new String[] {"scope1", "scope2"}); + tokenManager = new OAuth2TokenManager(config); + + when(mockClient.newCall(any(Request.class))).thenReturn(mockCall); + when(mockCall.execute()).thenReturn(mockResponse); + } + + @AfterAll + public static void closeMocks() throws Exception { + mocksCloser.close(); + } + + @Test + @DisplayName("Should refresh access token when expired") + void shouldRefreshAccessTokenWhenExpired() throws IOException { + // Arrange + when(mockResponse.isSuccessful()).thenReturn(true); + when(mockResponse.body()).thenReturn(mockResponseBody); + when(mockResponseBody.string()).thenReturn("{\"access_token\":\"newAccessToken\",\"expires_in\":\"3600\"}"); + tokenManager.setClient(mockClient); + + // Act + String accessToken = tokenManager.getAccessToken(); + + // Verify + assertNotNull(accessToken); + assertEquals("newAccessToken", accessToken); + } + + @Test + @DisplayName("Should reuse access token if not expired") + void shouldReuseAccessTokenIfNotExpired() throws IOException { + // Arrange + when(mockResponse.isSuccessful()).thenReturn(true); + when(mockResponse.body()).thenReturn(mockResponseBody); + when(mockResponseBody.string()).thenReturn("{\"access_token\":\"accessToken\",\"expires_in\":\"3600\"}"); + tokenManager.setClient(mockClient); + + // First call to get a new token + String firstAccessToken = tokenManager.getAccessToken(); + // Second call should reuse the same token + String secondAccessToken = tokenManager.getAccessToken(); + + // Verify + assertNotNull(firstAccessToken); + // Assert that the same token is reused + assertEquals(firstAccessToken, secondAccessToken); + } + + @Test + @DisplayName("Should throw AuthenticationException on unsuccessful response") + void shouldThrowAuthenticationExceptionOnUnsuccessfulResponse() { + // Arrange + when(mockResponse.isSuccessful()).thenReturn(false); + tokenManager.setClient(mockClient); + + // Act & Verify + assertThrows(AuthenticationException.class, () -> tokenManager.getAccessToken()); + } + + @Test + @DisplayName("Should throw AuthenticationException when response body is null") + void shouldThrowAuthenticationExceptionWhenResponseBodyIsNull() { + // Arrange + when(mockResponse.isSuccessful()).thenReturn(true); + when(mockResponse.body()).thenReturn(null); + tokenManager.setClient(mockClient); + + // Act & Verify + assertThrows(AuthenticationException.class, () -> tokenManager.getAccessToken()); + } +} diff --git a/camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenTest.java b/camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenTest.java new file mode 100644 index 0000000..572c108 --- /dev/null +++ b/camel-frappe-api/src/test/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenTest.java @@ -0,0 +1,51 @@ +package com.ozonehis.camel.frappe.sdk.internal.security.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class OAuth2TokenTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + @DisplayName("Should deserialize from JSON") + void shouldDeserializeFromJson() throws Exception { + // Arrange & Act + String json = "{\"access_token\":\"abc123\",\"expires_in\":\"3600\",\"token_type\":\"Bearer\"}"; + OAuth2Token token = objectMapper.readValue(json, OAuth2Token.class); + + // Verify + assertEquals("abc123", token.getAccessToken()); + assertEquals("3600", token.getExpiresIn()); + assertEquals("Bearer", token.getTokenType()); + } + + @Test + @DisplayName("Should ignore unknown properties") + void shouldIgnoreUnknownProperties() throws Exception { + // Arrange & Act + String json = + "{\"access_token\":\"abc123\",\"expires_in\":\"3600\",\"token_type\":\"Bearer\",\"unknown_prop\":\"value\"}"; + OAuth2Token token = objectMapper.readValue(json, OAuth2Token.class); + + // Verify + assertEquals("abc123", token.getAccessToken()); + } + + @Test + @DisplayName("Should handle null JSON") + void shouldHandleNullJson() throws Exception { + // Arrange & Act + String json = "{}"; + OAuth2Token token = objectMapper.readValue(json, OAuth2Token.class); + + // Verify + assertNull(token.getAccessToken()); + assertNull(token.getExpiresIn()); + assertNull(token.getTokenType()); + } +} diff --git a/camel-frappe-component/src/generated/resources/com/ozonehis/camel/frappe.json b/camel-frappe-component/src/generated/resources/com/ozonehis/camel/frappe.json index 1c40f4c..e9cb449 100644 --- a/camel-frappe-component/src/generated/resources/com/ozonehis/camel/frappe.json +++ b/camel-frappe-component/src/generated/resources/com/ozonehis/camel/frappe.json @@ -5,7 +5,7 @@ "title": "Frappe", "description": "Frappe component to integrate with Frappe REST API.", "deprecated": false, - "firstVersion": "1.1.0-SNAPSHOT", + "firstVersion": "1.0.0", "label": "api", "javaType": "com.ozonehis.camel.FrappeComponent", "supportLevel": "Preview",