From 225915bace85cf65f526bf10bc6de44d56bd0cf5 Mon Sep 17 00:00:00 2001 From: Kipchumba Bett Date: Thu, 28 Nov 2024 15:01:09 +0300 Subject: [PATCH] OZ-573: Add support to authenticate with `oauth2` via camel route (#41) --- .gitignore | 1 + .../com/ozonehis/eip/security/Constants.java | 24 ++++ .../eip/security/oauth2/OAuth2Processor.java | 77 ++++++++++++ .../eip/security/oauth2/OAuth2Properties.java | 32 +++++ .../eip/security/oauth2/OAuth2Route.java | 29 +++++ .../eip/security/oauth2/OAuth2Token.java | 39 +++++++ .../security/oauth2/OAuth2ProcessorTest.java | 110 ++++++++++++++++++ .../security/oauth2/OAuth2PropertiesTest.java | 62 ++++++++++ .../eip/security/oauth2/OAuth2RouteTest.java | 84 +++++++++++++ 9 files changed, 458 insertions(+) create mode 100644 commons/src/main/java/com/ozonehis/eip/security/Constants.java create mode 100644 commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Processor.java create mode 100644 commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Properties.java create mode 100644 commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Route.java create mode 100644 commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Token.java create mode 100644 commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2ProcessorTest.java create mode 100644 commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2PropertiesTest.java create mode 100644 commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2RouteTest.java diff --git a/.gitignore b/.gitignore index 56a0213..9be0f88 100644 --- a/.gitignore +++ b/.gitignore @@ -163,6 +163,7 @@ $RECYCLE.BIN/ # Windows shortcuts *.lnk +.java-version # End of https://www.gitignore.io/api/git,java,maven,eclipse,windows diff --git a/commons/src/main/java/com/ozonehis/eip/security/Constants.java b/commons/src/main/java/com/ozonehis/eip/security/Constants.java new file mode 100644 index 0000000..f821138 --- /dev/null +++ b/commons/src/main/java/com/ozonehis/eip/security/Constants.java @@ -0,0 +1,24 @@ +/* + * Copyright © 2021, Ozone HIS + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.ozonehis.eip.security; + +import lombok.NoArgsConstructor; + +@NoArgsConstructor +public class Constants { + + public static final String BEARER_HTTP_AUTH_SCHEME = "Bearer"; + + public static final String HEADER_OAUTH2_URL = "oauth2.url"; + + public static final String HEADER_OAUTH2_CLIENT_ID = "oauth2.client.id"; + + public static final String HEADER_OAUTH2_CLIENT_SECRET = "oauth2.client.secret"; + + public static final String HEADER_OAUTH2_SCOPE = "oauth2.client.scope"; +} diff --git a/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Processor.java b/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Processor.java new file mode 100644 index 0000000..cc2a7d1 --- /dev/null +++ b/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Processor.java @@ -0,0 +1,77 @@ +/* + * Copyright © 2021, Ozone HIS + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.ozonehis.eip.security.oauth2; + +import static com.ozonehis.eip.security.Constants.BEARER_HTTP_AUTH_SCHEME; +import static com.ozonehis.eip.security.Constants.HEADER_OAUTH2_CLIENT_ID; +import static com.ozonehis.eip.security.Constants.HEADER_OAUTH2_CLIENT_SECRET; +import static com.ozonehis.eip.security.Constants.HEADER_OAUTH2_SCOPE; +import static com.ozonehis.eip.security.Constants.HEADER_OAUTH2_URL; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.apache.camel.Exchange; +import org.apache.camel.Processor; +import org.apache.camel.ProducerTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Slf4j +@Setter +@Getter +@Component("eip.oauthProcessor") +public class OAuth2Processor implements Processor { + + private final ProducerTemplate producerTemplate; + + @Autowired + public OAuth2Processor(ProducerTemplate producerTemplate) { + this.producerTemplate = producerTemplate; + } + + @Override + public void process(Exchange exchange) { + OAuth2Properties properties = OAuth2Properties.builder() + .authUrl(exchange.getMessage().getHeader(HEADER_OAUTH2_URL, String.class)) + .clientScope(exchange.getMessage().getHeader(HEADER_OAUTH2_SCOPE, String.class)) + .clientId(exchange.getMessage().getHeader(HEADER_OAUTH2_CLIENT_ID, String.class)) + .clientSecret(exchange.getMessage().getHeader(HEADER_OAUTH2_CLIENT_SECRET, String.class)) + .build(); + validateOAuth2Properties(properties); + OAuth2Token authToken = callAccessTokenUri(properties.getAuthUrl(), buildOAuth2RequestBody(properties)); + if (authToken == null) { + throw new IllegalStateException("OAuth2 token is null"); + } else { + log.info("OAuth2 token is successfully obtained. Expires in {} seconds", authToken.getExpiresIn()); + } + setAuthorizationHeader(exchange, authToken.getAccessToken()); + } + + private void validateOAuth2Properties(OAuth2Properties properties) { + if (properties == null || !properties.isValid()) { + throw new IllegalStateException("OAuth2 properties are not set properly or some properties are missing"); + } + } + + private String buildOAuth2RequestBody(OAuth2Properties properties) { + return "grant_type=client_credentials" + "&client_id=" + + properties.getClientId() + "&client_secret=" + + properties.getClientSecret() + "&scope=" + + properties.getClientScope(); + } + + private OAuth2Token callAccessTokenUri(String accessTokenUri, String requestBody) { + return producerTemplate.requestBodyAndHeader( + "direct:oauth2", requestBody, HEADER_OAUTH2_URL, accessTokenUri, OAuth2Token.class); + } + + private void setAuthorizationHeader(Exchange exchange, String accessToken) { + exchange.getMessage().setHeader("Authorization", BEARER_HTTP_AUTH_SCHEME + " " + accessToken); + } +} diff --git a/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Properties.java b/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Properties.java new file mode 100644 index 0000000..d50b563 --- /dev/null +++ b/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Properties.java @@ -0,0 +1,32 @@ +/* + * Copyright © 2021, Ozone HIS + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.ozonehis.eip.security.oauth2; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class OAuth2Properties { + + private String authUrl; + + private String clientId; + + private String clientSecret; + + private String clientScope; + + public boolean isValid() { + return authUrl != null && clientId != null && clientSecret != null && clientScope != null; + } +} diff --git a/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Route.java b/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Route.java new file mode 100644 index 0000000..60082b9 --- /dev/null +++ b/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Route.java @@ -0,0 +1,29 @@ +/* + * Copyright © 2021, Ozone HIS + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.ozonehis.eip.security.oauth2; + +import static com.ozonehis.eip.security.Constants.HEADER_OAUTH2_URL; + +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.model.dataformat.JsonLibrary; +import org.springframework.stereotype.Component; + +@Component +public class OAuth2Route extends RouteBuilder { + + @Override + public void configure() { + from("direct:oauth2") + .routeId("oauth2") + .setHeader("Content-Type", constant("application/x-www-form-urlencoded")) + .setHeader("CamelHttpMethod", constant("POST")) + .toD("${header." + HEADER_OAUTH2_URL + "}") + .unmarshal() + .json(JsonLibrary.Jackson, OAuth2Token.class); + } +} diff --git a/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Token.java b/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Token.java new file mode 100644 index 0000000..b23ea8e --- /dev/null +++ b/commons/src/main/java/com/ozonehis/eip/security/oauth2/OAuth2Token.java @@ -0,0 +1,39 @@ +/* + * Copyright © 2021, Ozone HIS + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.ozonehis.eip.security.oauth2; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@EqualsAndHashCode +@JsonIgnoreProperties(ignoreUnknown = true) +public class OAuth2Token { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in") + private long expiresIn; + + @JsonProperty("refresh_expires_in") + private long refreshExpiresIn; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("not-before-policy") + private long notBeforePolicy; + + @JsonProperty("scope") + private String scope; +} diff --git a/commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2ProcessorTest.java b/commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2ProcessorTest.java new file mode 100644 index 0000000..e5d3ac4 --- /dev/null +++ b/commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2ProcessorTest.java @@ -0,0 +1,110 @@ +/* + * Copyright © 2021, Ozone HIS + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.ozonehis.eip.security.oauth2; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +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 org.apache.camel.Exchange; +import org.apache.camel.Message; +import org.apache.camel.ProducerTemplate; +import org.apache.camel.test.junit5.CamelTestSupport; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +public class OAuth2ProcessorTest extends CamelTestSupport { + + public static final String TEST_ACCESS_TOKEN = "testAccessToken"; + + public static final String KEYCLOAK_SERVER_URL_AUTH = "https://keycloak-server-url/auth"; + + @Mock + private ProducerTemplate producerTemplate; + + @InjectMocks + private OAuth2Processor oauth2Processor; + + private static AutoCloseable mocksCloser; + + @Mock + private Exchange exchange; + + @Mock + private Message message; + + @BeforeEach + public void setUp() { + mocksCloser = openMocks(this); + when(message.getHeader("oauth2.url", String.class)).thenReturn(KEYCLOAK_SERVER_URL_AUTH); + when(message.getHeader("oauth2.client.id", String.class)).thenReturn("clientId"); + when(message.getHeader("oauth2.client.secret", String.class)).thenReturn("clientSecret"); + when(message.getHeader("oauth2.client.scope", String.class)).thenReturn("scope"); + when(exchange.getMessage()).thenReturn(message); + } + + @AfterAll + public static void closeMocks() throws Exception { + mocksCloser.close(); + } + + @Test + public void shouldAddAuthorizationHeaderGivenClientIdAndSecret() { + // Setup + when(producerTemplate.requestBodyAndHeader( + anyString(), anyString(), anyString(), anyString(), eq(OAuth2Token.class))) + .thenReturn(getOauthToken()); + + // Replay + oauth2Processor.process(exchange); + + // Verify + verify(exchange.getMessage(), times(1)).setHeader(eq("Authorization"), eq("Bearer " + TEST_ACCESS_TOKEN)); + } + + @Test + public void shouldThrowEIPAuthenticationExceptionGivenNullClientScope() { + // Setup + String accessTokenUri = "https://test.auth.com/token"; + when(producerTemplate.requestBodyAndHeader( + anyString(), anyString(), eq("authUrl"), eq(accessTokenUri), eq(OAuth2Token.class))) + .thenReturn(getOauthToken()); + + // Replay & Verify + assertThrows(IllegalStateException.class, () -> oauth2Processor.process(exchange)); + } + + @Test + public void shouldThrowEIPAuthenticationExceptionGivenNullProperties() { + // Setup + String accessTokenUri = "https://test.auth.com/token"; + when(producerTemplate.requestBodyAndHeader( + anyString(), anyString(), eq("authUrl"), eq(accessTokenUri), eq(OAuth2Token.class))) + .thenReturn(getOauthToken()); + + // Replay & Verify + assertThrows(IllegalStateException.class, () -> oauth2Processor.process(exchange)); + } + + private static OAuth2Token getOauthToken() { + OAuth2Token oAuthToken = new OAuth2Token(); + oAuthToken.setAccessToken(TEST_ACCESS_TOKEN); + oAuthToken.setExpiresIn(3600); + oAuthToken.setRefreshExpiresIn(3600); + oAuthToken.setTokenType("Bearer"); + oAuthToken.setNotBeforePolicy(0); + return oAuthToken; + } +} diff --git a/commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2PropertiesTest.java b/commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2PropertiesTest.java new file mode 100644 index 0000000..dc8251f --- /dev/null +++ b/commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2PropertiesTest.java @@ -0,0 +1,62 @@ +/* + * Copyright © 2021, Ozone HIS + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.ozonehis.eip.security.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.Test; + +public class OAuth2PropertiesTest { + + @Test + public void shouldSetPropertiesCorrectlyWithBuilder() { + // Set the properties using the builder + OAuth2Properties properties = OAuth2Properties.builder() + .authUrl("https://example.com/auth") + .clientId("testClientId") + .clientSecret("testClientSecret") + .clientScope("testClientScope") + .build(); + + // Verify + assertEquals("https://example.com/auth", properties.getAuthUrl()); + assertEquals("testClientId", properties.getClientId()); + assertEquals("testClientSecret", properties.getClientSecret()); + assertEquals("testClientScope", properties.getClientScope()); + } + + @Test + public void shouldSetPropertiesCorrectlyWithDefaults() { + // set default properties + OAuth2Properties properties = OAuth2Properties.builder().build(); + + // Verify + assertNull(properties.getAuthUrl()); + assertNull(properties.getClientId()); + assertNull(properties.getClientSecret()); + assertNull(properties.getClientScope()); + } + + @Test + public void shouldSetPropertiesCorrectlyWithSetter() { + OAuth2Properties properties = new OAuth2Properties(); + + // Set the properties using the setter methods + properties.setAuthUrl("https://example.com/auth"); + properties.setClientId("testClientId"); + properties.setClientSecret("testClientSecret"); + properties.setClientScope("testClientScope"); + + // Verify + assertEquals("https://example.com/auth", properties.getAuthUrl()); + assertEquals("testClientId", properties.getClientId()); + assertEquals("testClientSecret", properties.getClientSecret()); + assertEquals("testClientScope", properties.getClientScope()); + } +} diff --git a/commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2RouteTest.java b/commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2RouteTest.java new file mode 100644 index 0000000..f2906e3 --- /dev/null +++ b/commons/src/test/java/com/ozonehis/eip/security/oauth2/OAuth2RouteTest.java @@ -0,0 +1,84 @@ +/* + * Copyright © 2021, Ozone HIS + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.ozonehis.eip.security.oauth2; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.ozonehis.eip.security.Constants; +import org.apache.camel.CamelContext; +import org.apache.camel.EndpointInject; +import org.apache.camel.Exchange; +import org.apache.camel.Produce; +import org.apache.camel.ProducerTemplate; +import org.apache.camel.builder.AdviceWith; +import org.apache.camel.builder.RouteBuilder; +import org.apache.camel.component.mock.MockEndpoint; +import org.apache.camel.impl.DefaultCamelContext; +import org.apache.camel.model.RouteDefinition; +import org.apache.camel.test.junit5.CamelTestSupport; +import org.junit.jupiter.api.Test; + +public class OAuth2RouteTest extends CamelTestSupport { + + public static final String OAUTH_REQUEST_BODY = + "grant_type=client_credentials&client_id=testClientId&client_secret=testClientSecret&scope=testClientScope"; + + @EndpointInject("mock:result") + private MockEndpoint mockEndpoint; + + @Produce("direct:oauth2") + private ProducerTemplate producerTemplate; + + @Override + protected CamelContext createCamelContext() throws Exception { + CamelContext context = new DefaultCamelContext(); + context.addRoutes(new OAuth2Route()); + return context; + } + + @Override + protected void doPreSetup() throws Exception { + super.doPreSetup(); + } + + @Test + public void shouldGetAccessTokenFromAuthProviderGivenOAuthPropsAsRequestBody() throws Exception { + // Setup + mockEndpoint.expectedMessageCount(1); + mockEndpoint.expectedBodiesReceived(OAUTH_REQUEST_BODY); + mockEndpoint.expectedHeaderReceived("Content-Type", "application/x-www-form-urlencoded"); + mockEndpoint.expectedHeaderReceived("CamelHttpMethod", "POST"); + + RouteDefinition routeDefinition = context.getRouteDefinition("oauth2"); + AdviceWith.adviceWith(routeDefinition, context, new RouteBuilder() { + + @Override + public void configure() { + // Replace the "toD" endpoint with a mock endpoint + interceptSendToEndpoint("https://example.com/auth") + .skipSendToOriginalEndpoint() + .to(mockEndpoint); + } + }); + + context.start(); + + Exchange exchange = createExchangeWithBody(OAUTH_REQUEST_BODY); + exchange.getMessage().setHeader(Constants.HEADER_OAUTH2_URL, "https://example.com/auth"); + + // Replay + producerTemplate.send("direct:oauth2", exchange); + + // Verify + mockEndpoint.assertIsSatisfied(); + + // Verify + String oAuthToken = exchange.getMessage().getBody(String.class); + assertNotNull(oAuthToken); + } +}