From 7d3ffba0cf5c92cd00737d1347b8a22ebccbbc3e Mon Sep 17 00:00:00 2001 From: Jakub Balhar Date: Thu, 25 Jun 2026 12:32:25 +0200 Subject: [PATCH 1/2] fix: validate token before HA distribution in doInvalidate for #4754 - Fix 1: Move parseJwtToken() before peer distribution in doInvalidate() to reject invalid tokens immediately instead of distributing them first. Catches TokenNotValidException/TokenExpireException/TokenFormatNotValidException before any peer calls. Retains BadCredentialsException suppression for legitimate ZOSMF rejection after successful peer distribution. - Fix 2: Add null/empty guards at invalidateJwtToken() and invalidateJwtTokenGateway() entry points, throwing TokenNotProvidedException for defense-in-depth. - Fix 3: Add empty-value filter to HttpUtils.getCookieValue() to match ZAAS getTokenFromCookie() behavior, preventing empty cookies from reaching authentication service in modulith HA mode. - Tests: 7 new tests added (HttpUtilsTest + AuthenticationServiceTest), 2 existing tests fixed to use real JWT tokens for parse-before-distribute. All 46 AuthenticationService tests pass, all HttpUtils tests pass. --- .../java/org/zowe/apiml/util/HttpUtils.java | 3 +- .../org/zowe/apiml/util/HttpUtilsTest.java | 13 ++++ .../service/AuthenticationService.java | 16 +++- .../service/AuthenticationServiceTest.java | 74 ++++++++++++++++++- 4 files changed, 102 insertions(+), 4 deletions(-) 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-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()); + } + } } From 0d9e6f4f5d71373b5c2d523f3c8bcc74961fc6f0 Mon Sep 17 00:00:00 2001 From: Jakub Balhar Date: Thu, 25 Jun 2026 19:36:07 +0200 Subject: [PATCH 2/2] fix: add null/empty guard in ZaasClientImpl.logout (#4754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty token logout was not throwing ZaasClientException because: 1. ZaasClientImpl.logout('') delegates to ZaasJwtService.logout('') 2. logoutJwtToken('') sends cookie with empty value 3. Gateway's getTokenFromRequest filters out empty cookie values 4. flatMap never runs → Spring Security treats as success → 2xx 5. doLogoutRequest gets success → no exception thrown Fix: add null/empty check in ZaasClientImpl.logout() matching the pattern used by login(), query(), passTicket(), and validateOidc(). Added unit tests for null and empty token cases. --- .../zaasclient/service/internal/ZaasClientImpl.java | 3 +++ .../zaasclient/service/internal/ZaasClientTest.java | 10 ++++++++++ 2 files changed, 13 insertions(+) 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();