diff --git a/apiml/src/main/java/org/zowe/apiml/util/HttpUtils.java b/apiml/src/main/java/org/zowe/apiml/util/HttpUtils.java index 4dc7e68457..240273a1b4 100644 --- a/apiml/src/main/java/org/zowe/apiml/util/HttpUtils.java +++ b/apiml/src/main/java/org/zowe/apiml/util/HttpUtils.java @@ -75,6 +75,7 @@ public Mono getBearerTokenFromHeaderReactive(ServerWebExchange exchange) public Mono getCookieValue(ServerWebExchange exchange, String cookieName) { return Mono.justOrEmpty(exchange) .mapNotNull(ex -> ex.getRequest().getCookies().getFirst(cookieName)) - .map(HttpCookie::getValue); + .map(HttpCookie::getValue) + .filter(value -> !value.isEmpty()); } } diff --git a/apiml/src/test/java/org/zowe/apiml/util/HttpUtilsTest.java b/apiml/src/test/java/org/zowe/apiml/util/HttpUtilsTest.java index 6fb7e14805..453f346d57 100644 --- a/apiml/src/test/java/org/zowe/apiml/util/HttpUtilsTest.java +++ b/apiml/src/test/java/org/zowe/apiml/util/HttpUtilsTest.java @@ -66,6 +66,19 @@ void shouldExtractTokenFromCookie() { .verifyComplete(); } + @Test + void shouldReturnEmptyForEmptyCookieValue() { + var cookie = new HttpCookie("apimlAuthenticationToken", ""); + var request = MockServerHttpRequest.get("/logout") + .cookie(cookie) + .build(); + + var exchange = MockServerWebExchange.from(request); + + StepVerifier.create(httpUtils.getTokenFromRequest(exchange)) + .verifyComplete(); + } + @Test void shouldExtractTokenFromAuthorizationHeader() { var request = MockServerHttpRequest.get("/logout") diff --git a/zaas-client/src/main/java/org/zowe/apiml/zaasclient/service/internal/ZaasClientImpl.java b/zaas-client/src/main/java/org/zowe/apiml/zaasclient/service/internal/ZaasClientImpl.java index e4facd1604..6b3d1202b9 100644 --- a/zaas-client/src/main/java/org/zowe/apiml/zaasclient/service/internal/ZaasClientImpl.java +++ b/zaas-client/src/main/java/org/zowe/apiml/zaasclient/service/internal/ZaasClientImpl.java @@ -143,6 +143,9 @@ public String passTicket(String jwtToken, String applicationId) throws ZaasClien @Override public void logout(String jwtToken) throws ZaasConfigurationException, ZaasClientException { + if (jwtToken == null || jwtToken.isEmpty()) { + throw new ZaasClientException(ZaasClientErrorCodes.TOKEN_NOT_PROVIDED); + } tokens.logout(jwtToken); } diff --git a/zaas-client/src/test/java/org/zowe/apiml/zaasclient/service/internal/ZaasClientTest.java b/zaas-client/src/test/java/org/zowe/apiml/zaasclient/service/internal/ZaasClientTest.java index fd8150e784..d60e479289 100644 --- a/zaas-client/src/test/java/org/zowe/apiml/zaasclient/service/internal/ZaasClientTest.java +++ b/zaas-client/src/test/java/org/zowe/apiml/zaasclient/service/internal/ZaasClientTest.java @@ -210,6 +210,16 @@ void givenValidToken_whenLogoutIsCalled_thenSuccessLogout() { assertDoesNotThrow(() -> underTest.logout("apimlAuthenticationToken=" + VALID_TOKEN)); } + @Test + void givenEmptyToken_whenLogout_thenThrowsException() { + assertThrows(ZaasClientException.class, () -> underTest.logout("")); + } + + @Test + void givenNullToken_whenLogout_thenThrowsException() { + assertThrows(ZaasClientException.class, () -> underTest.logout(null)); + } + @Test void givenNullKeyStorePath_whenTheClientIsConstructed_thenExceptionIsThrown() { ConfigProperties config = new ConfigProperties(); diff --git a/zaas-service/src/main/java/org/zowe/apiml/zaas/security/service/AuthenticationService.java b/zaas-service/src/main/java/org/zowe/apiml/zaas/security/service/AuthenticationService.java index 29ce2ac0bf..6aaaf70cc3 100644 --- a/zaas-service/src/main/java/org/zowe/apiml/zaas/security/service/AuthenticationService.java +++ b/zaas-service/src/main/java/org/zowe/apiml/zaas/security/service/AuthenticationService.java @@ -187,6 +187,9 @@ public QueryResponse parseJwtWithSignature(String jwt) { */ public Boolean invalidateJwtToken(String jwtToken, boolean distribute) { log.debug("Invalidating JWT: ...{}", StringUtils.right(jwtToken, 15)); + if (jwtToken == null || jwtToken.isEmpty()) { + throw new TokenNotProvidedException("No JWT token provided for invalidation"); + } if (jwtToken != null && isInvalidated(jwtToken)) { return Boolean.TRUE; } @@ -210,6 +213,15 @@ private Boolean doInvalidateAndUpdateCaches(String jwtToken, boolean distribute, } private Boolean doInvalidate(String jwtToken, boolean distribute, Application app) { + // Validate token format FIRST — before any peer calls + // If token is unparseable, throw immediately with descriptive exception + final QueryResponse queryResponse; + try { + queryResponse = parseJwtToken(jwtToken).getQueryResponse(); + } catch (TokenNotValidException | TokenExpireException | TokenFormatNotValidException e) { + throw e; // Always propagate — never suppress based on HA state + } + boolean isInvalidatedOnAnotherInstance = false; if (distribute) { isInvalidatedOnAnotherInstance = invalidateTokenOnAnotherInstance(jwtToken, app); @@ -218,7 +230,6 @@ private Boolean doInvalidate(String jwtToken, boolean distribute, Application ap } } - final QueryResponse queryResponse = parseJwtToken(jwtToken).getQueryResponse(); try { switch (queryResponse.getSource()) { case ZOWE: @@ -264,6 +275,9 @@ private void putInvalidatedCache(String jwtToken) { * @return state of invalidate (true - token was invalidated) */ public Boolean invalidateJwtTokenGateway(String jwtToken, boolean distribute, Application app) { + if (jwtToken == null || jwtToken.isEmpty()) { + throw new TokenNotProvidedException("No JWT token provided for invalidation"); + } if (jwtToken != null && isInvalidated(jwtToken)) { return Boolean.TRUE; } diff --git a/zaas-service/src/test/java/org/zowe/apiml/zaas/security/service/AuthenticationServiceTest.java b/zaas-service/src/test/java/org/zowe/apiml/zaas/security/service/AuthenticationServiceTest.java index 185bf6f995..03a4a54a31 100644 --- a/zaas-service/src/test/java/org/zowe/apiml/zaas/security/service/AuthenticationServiceTest.java +++ b/zaas-service/src/test/java/org/zowe/apiml/zaas/security/service/AuthenticationServiceTest.java @@ -56,6 +56,7 @@ import org.zowe.apiml.security.common.token.TokenAuthentication; import org.zowe.apiml.security.common.token.TokenExpireException; import org.zowe.apiml.security.common.token.TokenNotValidException; +import org.zowe.apiml.security.common.token.TokenNotProvidedException; import org.zowe.apiml.security.common.util.JWTTestUtils; import org.zowe.apiml.security.common.util.JwtUtils; import org.zowe.apiml.util.CacheUtils; @@ -449,8 +450,11 @@ class GivenInvalidateZosmfTokenTest { @Test void givenNoInstancesAvailable_thenReturnFalse() { + stubJWTSecurityForSign(); + authConfigurationProperties.getTokenProperties().setIssuer(ZOSMF); + String token = authService.createJwtToken("user", "dom", null); when(eurekaClient.getApplication(CoreService.ZAAS.getServiceId())).thenReturn(null); - assertFalse(authService.invalidateJwtToken(JWT_TOKEN, true)); + assertFalse(authService.invalidateJwtToken(token, true)); } @Test @@ -737,7 +741,8 @@ void whenInstancesAvailable_thenReturnSuccess() { @Test void givenHttpClientErrorOnInvalidateAnotherInstance_thenReturnFalse() { - String token = "jwtToken"; + stubJWTSecurityForSign(); + String token = authService.createJwtToken("user", "dom", null); Application application = mock(Application.class); ApplicationInfoManager applicationInfoManager = mock(ApplicationInfoManager.class); @@ -768,4 +773,69 @@ void givenHttpClientErrorOnInvalidateAnotherInstance_thenReturnFalse() { } } + + @Nested + class GivenTokenInvalidationTokenValidationTest { + + @Test + void whenEmptyToken_thenThrowTokenNotProvidedException() { + assertThrows(TokenNotProvidedException.class, () -> + authService.invalidateJwtToken("", true)); + } + + @Test + void whenNullToken_thenThrowTokenNotProvidedException() { + assertThrows(TokenNotProvidedException.class, () -> + authService.invalidateJwtToken(null, true)); + } + + @Test + void whenEmptyTokenGateway_thenThrowTokenNotProvidedException() { + Application app = mock(Application.class); + assertThrows(TokenNotProvidedException.class, () -> + authService.invalidateJwtTokenGateway("", true, app)); + } + + @Test + void whenNullTokenGateway_thenThrowTokenNotProvidedException() { + Application app = mock(Application.class); + assertThrows(TokenNotProvidedException.class, () -> + authService.invalidateJwtTokenGateway(null, true, app)); + } + + @Test + void whenUnparseableToken_thenThrowBeforeDistribution() { + // A non-empty, non-null token that parseJwtToken cannot parse + // Verification: with distribute=true, the distribution should never happen + // because parseJwtToken throws TokenNotValidException first + assertThrows(TokenNotValidException.class, () -> + authService.invalidateJwtToken("not_a_valid_jwt_token", true)); + } + + @Test + void whenValidToken_thenDistributionStillHappens() { + Application application = mock(Application.class); + ApplicationInfoManager applicationInfoManager = mock(ApplicationInfoManager.class); + InstanceInfo instanceInfo = mock(InstanceInfo.class); + InstanceInfo otherInstance = mock(InstanceInfo.class); + + stubJWTSecurityForSign(); + String token = authService.createJwtToken("user", "dom", null); + + when(eurekaClient.getApplication(CoreService.ZAAS.getServiceId())).thenReturn(application); + when(eurekaClient.getApplicationInfoManager()).thenReturn(applicationInfoManager); + when(applicationInfoManager.getInfo()).thenReturn(instanceInfo); + when(instanceInfo.getInstanceId()).thenReturn("self"); + when(application.getInstances()).thenReturn(List.of(instanceInfo, otherInstance)); + when(otherInstance.getInstanceId()).thenReturn("peer"); + when(otherInstance.getSecurePort()).thenReturn(100); + when(otherInstance.getHostName()).thenReturn("localhost"); + doNothing().when(restTemplate).delete(anyString()); + + assertTrue(authService.invalidateJwtToken(token, true)); + + // Verify that peer distribution happened (parseJwtToken succeeded first) + verify(restTemplate, times(1)).delete(anyString()); + } + } }