-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
OZ-529: Add support for OAuth2 authentication
- Loading branch information
1 parent
08cd414
commit c107031
Showing
9 changed files
with
392 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
21 changes: 21 additions & 0 deletions
21
...pi/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Config.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
31 changes: 31 additions & 0 deletions
31
...c/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Interceptor.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} | ||
} |
27 changes: 27 additions & 0 deletions
27
...api/src/main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2Token.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
60 changes: 60 additions & 0 deletions
60
.../main/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenManager.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()); | ||
} | ||
} | ||
} | ||
} |
79 changes: 79 additions & 0 deletions
79
...st/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2InterceptorTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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)); | ||
} | ||
} |
116 changes: 116 additions & 0 deletions
116
...t/java/com/ozonehis/camel/frappe/sdk/internal/security/oauth2/OAuth2TokenManagerTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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()); | ||
} | ||
} |
Oops, something went wrong.