Skip to content

Commit

Permalink
OZ-529: Add support for OAuth2 authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
corneliouzbett committed Jul 10, 2024
1 parent 08cd414 commit c107031
Show file tree
Hide file tree
Showing 9 changed files with 392 additions and 1 deletion.
6 changes: 6 additions & 0 deletions camel-frappe-api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@
<version>5.8.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>${okhttp.version}</version>
<scope>test</scope>
</dependency>

</dependencies>

Expand Down
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;
}
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);
}
}
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;
}
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());
}
}
}
}
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));
}
}
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());
}
}
Loading

0 comments on commit c107031

Please sign in to comment.