Skip to content

Commit

Permalink
APPPOCTOOL-28 Use folio-auth-openid library for JWT validation (#137)
Browse files Browse the repository at this point in the history
  • Loading branch information
pfilippov-epam authored Sep 17, 2024
1 parent 0cda267 commit e4a29b3
Show file tree
Hide file tree
Showing 9 changed files with 107 additions and 28 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,9 @@ The feature is controlled by two env variables `SECURITY_ENABLED` and `KEYCLOAK_
| KC_CLIENT_TLS_TRUSTSTORE_PATH | - | false | Truststore file path for keycloak clients. |
| KC_CLIENT_TLS_TRUSTSTORE_PASSWORD | - | false | Truststore password for keycloak clients. |
| KC_CLIENT_TLS_TRUSTSTORE_TYPE | - | false | Truststore file type for keycloak clients. |
| KC_AUTH_TOKEN_VALIDATE_URI | false | false | Defines if validation for JWT must be run to compare configuration URL and token issuer for keycloak. |
| KC_JWKS_REFRESH_INTERVAL | 60 | false | Jwks refresh interval for realm JWT parser (in minutes). |
| KC_FORCED_JWKS_REFRESH_INTERVAL | 60 | false | Forced jwks refresh interval for realm JWT parser (used in signing key rotation, in minutes). |

## Kong Gateway Integration

Expand Down
4 changes: 4 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ application:
enabled: ${KC_IMPORT_ENABLED:false}
client:
client_id: ${KC_CLIENT_ID:mgr-tenant-entitlements}
jwt-cache-configuration:
validate-uri: ${KC_AUTH_TOKEN_VALIDATE_URI:false}
jwks-refresh-interval: ${KC_JWKS_REFRESH_INTERVAL:60}
forced-jwks-refresh-interval: ${KC_FORCED_JWKS_REFRESH_INTERVAL:60}
environment: ${ENV:folio}
kafka:
send-duration-timeout: ${KAFKA_SEND_DURATION_TIMEOUT:10s}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,21 @@
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.folio.common.utils.OkapiHeaders;
import org.folio.entitlement.domain.dto.Entitlement;
import org.folio.entitlement.domain.dto.EntitlementRequestBody;
import org.folio.entitlement.domain.dto.ExtendedEntitlements;
import org.folio.entitlement.domain.model.EntitlementRequest;
import org.folio.entitlement.service.EntitlementService;
import org.folio.entitlement.service.FlowStageService;
import org.folio.jwt.openid.JsonWebTokenParser;
import org.folio.security.exception.NotAuthorizedException;
import org.folio.security.integration.keycloak.client.KeycloakAuthClient;
import org.folio.security.integration.keycloak.service.KeycloakTokenValidator;
import org.folio.test.extensions.EnableKeycloakSecurity;
import org.folio.test.types.UnitTest;
import org.junit.jupiter.api.Test;
import org.keycloak.representations.oidc.TokenMetadataRepresentation;
import org.mockito.Mock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
Expand All @@ -63,17 +64,23 @@
@TestPropertySource(properties = "application.router.path-prefix=/")
class EntitlementControllerTest {

private static final String TOKEN_ISSUER = "https://keycloak/realms/test";
private static final String TOKEN_SUB = UUID.randomUUID().toString();

@Autowired private MockMvc mockMvc;
@Mock private JsonWebToken jsonWebToken;
@MockBean private EntitlementService entitlementService;
@MockBean private KeycloakAuthClient authClient;
@MockBean private KeycloakTokenValidator keycloakTokenValidator;
@MockBean private JsonWebTokenParser jsonWebTokenParser;

@Test
void create_positive() throws Exception {
var requestBody = new EntitlementRequestBody().tenantId(TENANT_ID).applications(List.of(APPLICATION_ID));
var expectedEntitlements = entitlements();
when(entitlementService.performRequest(entitlementRequest())).thenReturn(expectedEntitlements);
when(keycloakTokenValidator.validateAndDecodeToken(OKAPI_TOKEN)).thenReturn(accessToken());
when(jsonWebTokenParser.parse(OKAPI_TOKEN)).thenReturn(jsonWebToken);
when(jsonWebToken.getIssuer()).thenReturn(TOKEN_ISSUER);
when(jsonWebToken.getSubject()).thenReturn(TOKEN_SUB);

var mvcResult = mockMvc.perform(post("/entitlements")
.header(OkapiHeaders.TOKEN, OKAPI_TOKEN)
Expand Down Expand Up @@ -130,7 +137,9 @@ void delete_positive() throws Exception {
var request = new EntitlementRequestBody().tenantId(TENANT_ID).applications(List.of(APPLICATION_ID));
var expectedEntitlements = entitlements();
when(entitlementService.performRequest(revokeRequest())).thenReturn(expectedEntitlements);
when(keycloakTokenValidator.validateAndDecodeToken(OKAPI_TOKEN)).thenReturn(accessToken());
when(jsonWebTokenParser.parse(OKAPI_TOKEN)).thenReturn(jsonWebToken);
when(jsonWebToken.getIssuer()).thenReturn(TOKEN_ISSUER);
when(jsonWebToken.getSubject()).thenReturn(TOKEN_SUB);

var mvcResult = mockMvc.perform(delete("/entitlements")
.header(OkapiHeaders.TOKEN, OKAPI_TOKEN)
Expand All @@ -148,7 +157,10 @@ void delete_positive() throws Exception {

@Test
void delete_negative_requestWithoutBody() throws Exception {
when(keycloakTokenValidator.validateAndDecodeToken(OKAPI_TOKEN)).thenReturn(accessToken());
when(jsonWebTokenParser.parse(OKAPI_TOKEN)).thenReturn(jsonWebToken);
when(jsonWebToken.getIssuer()).thenReturn(TOKEN_ISSUER);
when(jsonWebToken.getSubject()).thenReturn(TOKEN_SUB);

mockMvc.perform(delete("/entitlements", TENANT_ID)
.queryParam("tenantParameters", TENANT_PARAMETERS)
.queryParam("purge", String.valueOf(PURGE))
Expand All @@ -164,7 +176,10 @@ void delete_negative_requestWithoutBody() throws Exception {

@Test
void delete_negative_unsupportedContentType() throws Exception {
when(keycloakTokenValidator.validateAndDecodeToken(OKAPI_TOKEN)).thenReturn(accessToken());
when(jsonWebTokenParser.parse(OKAPI_TOKEN)).thenReturn(jsonWebToken);
when(jsonWebToken.getIssuer()).thenReturn(TOKEN_ISSUER);
when(jsonWebToken.getSubject()).thenReturn(TOKEN_SUB);

mockMvc.perform(delete("/entitlements", TENANT_ID)
.header(OkapiHeaders.TOKEN, OKAPI_TOKEN)
.contentType(APPLICATION_XML))
Expand Down Expand Up @@ -200,7 +215,9 @@ void upgrade_positive() throws Exception {
var requestBody = new EntitlementRequestBody().tenantId(TENANT_ID).applications(List.of(APPLICATION_ID));
var expectedEntitlements = entitlements();
when(entitlementService.performRequest(upgradeRequest())).thenReturn(expectedEntitlements);
when(keycloakTokenValidator.validateAndDecodeToken(OKAPI_TOKEN)).thenReturn(accessToken());
when(jsonWebTokenParser.parse(OKAPI_TOKEN)).thenReturn(jsonWebToken);
when(jsonWebToken.getIssuer()).thenReturn(TOKEN_ISSUER);
when(jsonWebToken.getSubject()).thenReturn(TOKEN_SUB);

mockMvc.perform(put("/entitlements")
.header(OkapiHeaders.TOKEN, OKAPI_TOKEN)
Expand Down Expand Up @@ -264,11 +281,4 @@ private static ExtendedEntitlements entitlements() {
return new ExtendedEntitlements().totalRecords(1).flowId(FLOW_ID).addEntitlementsItem(
new Entitlement().applicationId(APPLICATION_ID).tenantId(TENANT_ID));
}

private static TokenMetadataRepresentation accessToken() {
var tokenMetadataRepresentation = new TokenMetadataRepresentation();
tokenMetadataRepresentation.issuer("https://keycloak/realms/test");
tokenMetadataRepresentation.setSubject(UUID.randomUUID().toString());
return tokenMetadataRepresentation;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,22 @@

import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.folio.common.utils.OkapiHeaders;
import org.folio.entitlement.controller.converter.EntitlementTypeConverters;
import org.folio.entitlement.domain.dto.EntitlementRequestBody;
import org.folio.entitlement.domain.dto.EntitlementType;
import org.folio.entitlement.domain.model.EntitlementRequest;
import org.folio.entitlement.service.EntitlementValidationService;
import org.folio.entitlement.service.FlowStageService;
import org.folio.jwt.openid.JsonWebTokenParser;
import org.folio.security.integration.keycloak.client.KeycloakAuthClient;
import org.folio.security.integration.keycloak.service.KeycloakTokenValidator;
import org.folio.test.extensions.EnableKeycloakSecurity;
import org.folio.test.types.UnitTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import org.keycloak.representations.oidc.TokenMetadataRepresentation;
import org.mockito.Mock;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
Expand All @@ -49,8 +50,12 @@
@TestPropertySource(properties = "application.router.path-prefix=/")
class EntitlementValidationControllerTest {

private static final String TOKEN_ISSUER = "https://keycloak/realms/test";
private static final String TOKEN_SUB = UUID.randomUUID().toString();

@Autowired private MockMvc mockMvc;
@MockBean private KeycloakTokenValidator keycloakTokenValidator;
@Mock private JsonWebToken jsonWebToken;
@MockBean private JsonWebTokenParser jsonWebTokenParser;
@MockBean private EntitlementValidationService validationService;

@ParameterizedTest
Expand All @@ -59,7 +64,9 @@ void validate_positive(EntitlementType type) throws Exception {
var requestBody = new EntitlementRequestBody().tenantId(TENANT_ID).applications(List.of(APPLICATION_ID));

doNothing().when(validationService).validate(entitlementRequest(type));
when(keycloakTokenValidator.validateAndDecodeToken(OKAPI_TOKEN)).thenReturn(accessToken());
when(jsonWebTokenParser.parse(OKAPI_TOKEN)).thenReturn(jsonWebToken);
when(jsonWebToken.getIssuer()).thenReturn(TOKEN_ISSUER);
when(jsonWebToken.getSubject()).thenReturn(TOKEN_SUB);

mockMvc.perform(post("/entitlements/validate")
.header(OkapiHeaders.TOKEN, OKAPI_TOKEN)
Expand All @@ -75,7 +82,9 @@ void validate_positive_withValidatorName() throws Exception {
var requestBody = new EntitlementRequestBody().tenantId(TENANT_ID).applications(List.of(APPLICATION_ID));

doNothing().when(validationService).validateBy(validator, entitlementRequest());
when(keycloakTokenValidator.validateAndDecodeToken(OKAPI_TOKEN)).thenReturn(accessToken());
when(jsonWebTokenParser.parse(OKAPI_TOKEN)).thenReturn(jsonWebToken);
when(jsonWebToken.getIssuer()).thenReturn(TOKEN_ISSUER);
when(jsonWebToken.getSubject()).thenReturn(TOKEN_SUB);

mockMvc.perform(post("/entitlements/validate")
.header(OkapiHeaders.TOKEN, OKAPI_TOKEN)
Expand All @@ -89,7 +98,9 @@ void validate_positive_withValidatorName() throws Exception {
@Test
void validate_negative_invalidEntitleType() throws Exception {
var requestBody = new EntitlementRequestBody().tenantId(TENANT_ID).applications(List.of(APPLICATION_ID));
when(keycloakTokenValidator.validateAndDecodeToken(OKAPI_TOKEN)).thenReturn(accessToken());
when(jsonWebTokenParser.parse(OKAPI_TOKEN)).thenReturn(jsonWebToken);
when(jsonWebToken.getIssuer()).thenReturn(TOKEN_ISSUER);
when(jsonWebToken.getSubject()).thenReturn(TOKEN_SUB);

String invalidType = "invalidType";
mockMvc.perform(post("/entitlements/validate")
Expand Down Expand Up @@ -117,11 +128,4 @@ private static EntitlementRequest entitlementRequest(EntitlementType type) {
.okapiToken(OKAPI_TOKEN)
.build();
}

private static TokenMetadataRepresentation accessToken() {
var tokenMetadataRepresentation = new TokenMetadataRepresentation();
tokenMetadataRepresentation.issuer("https://keycloak/realms/test");
tokenMetadataRepresentation.setSubject(UUID.randomUUID().toString());
return tokenMetadataRepresentation;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@
import org.folio.entitlement.support.KeycloakTestClientConfiguration;
import org.folio.entitlement.support.KeycloakTestClientConfiguration.KeycloakTestClient;
import org.folio.entitlement.support.base.BaseIntegrationTest;
import org.folio.jwt.openid.OpenidJwtParserProvider;
import org.folio.test.extensions.EnableKeycloakSecurity;
import org.folio.test.extensions.EnableKeycloakTlsMode;
import org.folio.test.extensions.KeycloakRealms;
import org.folio.test.extensions.WireMockStub;
import org.folio.test.types.IntegrationTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
Expand All @@ -40,6 +42,15 @@ class EntitlementApplicationServiceIT extends BaseIntegrationTest {

@Autowired private KeycloakTestClient keycloakTestClient;

/**
* Invalidates cache before each test because keycloak realm is always recreates, so it prevents old keys to work for
* newly issued tokens.
*/
@BeforeEach
void setUp(@Autowired OpenidJwtParserProvider openidJwtParserProvider) {
openidJwtParserProvider.invalidateCache();
}

@Test
@WireMockStub(scripts = {
"/wiremock/mgr-tenants/test/get-query-by-name.json",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package org.folio.entitlement.it;

import org.folio.entitlement.support.KeycloakTestClientConfiguration.KeycloakTestClient;
import org.folio.jwt.openid.OpenidJwtParserProvider;
import org.folio.test.types.IntegrationTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.keycloak.admin.client.Keycloak;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource;
Expand All @@ -27,6 +29,15 @@ static void beforeAll(@Autowired Keycloak keycloak) {
System.setProperty(SYSTEM_ACCESS_TOKEN_SYSTEM_PROPERTY_KEY, accessTokenString);
}

/**
* Invalidates cache before each test because keycloak realm is always recreates, so it prevents old keys to work for
* newly issued tokens.
*/
@BeforeEach
void setUp(@Autowired OpenidJwtParserProvider openidJwtParserProvider) {
openidJwtParserProvider.invalidateCache();
}

@AfterAll
static void afterAll() {
System.clearProperty(ROUTER_PATH_PREFIX_SYSTEM_PROPERTY_KEY);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package org.folio.entitlement.it;

import org.folio.entitlement.support.KeycloakTestClientConfiguration.KeycloakTestClient;
import org.folio.jwt.openid.OpenidJwtParserProvider;
import org.folio.test.types.IntegrationTest;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.keycloak.admin.client.Keycloak;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.context.TestPropertySource;
Expand All @@ -25,6 +27,15 @@ static void beforeAll(@Autowired Keycloak keycloak) {
System.setProperty(SYSTEM_ACCESS_TOKEN_SYSTEM_PROPERTY_KEY, accessTokenString);
}

/**
* Invalidates cache before each test because keycloak realm is always recreates, so it prevents old keys to work for
* newly issued tokens.
*/
@BeforeEach
void setUp(@Autowired OpenidJwtParserProvider openidJwtParserProvider) {
openidJwtParserProvider.invalidateCache();
}

@AfterAll
static void afterAll() {
System.clearProperty(SYSTEM_ACCESS_TOKEN_SYSTEM_PROPERTY_KEY);
Expand Down
20 changes: 20 additions & 0 deletions src/test/java/org/folio/entitlement/support/TestUtils.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.folio.entitlement.support;

import static javax.net.ssl.HttpsURLConnection.setDefaultHostnameVerifier;
import static javax.net.ssl.HttpsURLConnection.setDefaultSSLSocketFactory;
import static javax.net.ssl.SSLContext.getInstance;
import static org.folio.common.utils.ExceptionHandlerUtils.buildValidationError;
import static org.mockito.Mockito.when;
Expand All @@ -26,8 +28,10 @@
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLSession;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509ExtendedTrustManager;
import lombok.AccessLevel;
Expand Down Expand Up @@ -173,6 +177,22 @@ public static BadRequest createBadRequest(String body) {
return (BadRequest) FeignException.errorStatus("someMethod", response);
}

public static void disableSslVerification() {
try {
var sc = dummySslContext();
setDefaultSSLSocketFactory(sc.getSocketFactory());
var allHostsValid = new HostnameVerifier() {
public boolean verify(String hostname, SSLSession session) {
return true;
}
};

setDefaultHostnameVerifier(allHostsValid);
} catch (Exception e) {
throw new RuntimeException("Failed to disable SSL verification", e);
}
}

public static HttpClient httpClientWithDummySslContext() {
return HttpClient.newBuilder().sslContext(dummySslContext()).build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import org.folio.entitlement.domain.dto.Entitlements;
import org.folio.entitlement.domain.dto.ExtendedEntitlements;
import org.folio.entitlement.exception.RequestValidationException;
import org.folio.entitlement.support.TestUtils;
import org.folio.entitlement.support.extensions.EnableKongGateway;
import org.folio.entitlement.support.extensions.EnablePostgres;
import org.folio.test.FakeKafkaConsumer;
Expand Down Expand Up @@ -77,6 +78,10 @@ public abstract class BaseIntegrationTest extends BaseBackendIntegrationTest {
protected static FakeKafkaConsumer fakeKafkaConsumer;
protected static WireMockAdminClient wmAdminClient;

static {
TestUtils.disableSslVerification();
}

@BeforeAll
static void setUp(@Autowired FakeKafkaConsumer consumer) {
fakeKafkaConsumer = consumer;
Expand Down

0 comments on commit e4a29b3

Please sign in to comment.