gmunozfe commented on code in PR #4000: URL: https://github.com/apache/incubator-kie-kogito-runtimes/pull/4000#discussion_r2266451361
########## quarkus/extensions/kogito-quarkus-serverless-workflow-jdbc-token-persistence-extension/kogito-quarkus-serverless-workflow-jdbc-token-persistence-integration-test/pom.xml: ########## @@ -0,0 +1,314 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + ~ Licensed to the Apache Software Foundation (ASF) under one + ~ or more contributor license agreements. See the NOTICE file + ~ distributed with this work for additional information + ~ regarding copyright ownership. The ASF licenses this file + ~ to you under the Apache License, Version 2.0 (the + ~ "License"); you may not use this file except in compliance + ~ with the License. You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, + ~ software distributed under the License is distributed on an + ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + ~ KIND, either express or implied. See the License for the + ~ specific language governing permissions and limitations + ~ under the License. + --> + +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <artifactId>sonataflow-quarkus-jdbc-token-persistence-extension</artifactId> + <groupId>org.apache.kie.sonataflow</groupId> + <version>999-SNAPSHOT</version> + </parent> + <modelVersion>4.0.0</modelVersion> + + <artifactId>sonataflow-quarkus-jdbc-token-persistence-integration-test</artifactId> + <description>SonataFlow :: Quarkus Serverless Workflow JDBC Token Persistence Extension :: Integration Tests</description> + + + <properties> + <quarkus.test.list.include>true</quarkus.test.list.include> + <!-- base path for serverless workflow files that are being used for testing the parsing specific unit tests, and + that we also want to use for testing them executing. --> + <java.module.name>org.kie.kogito.quarkus.workflows.tests</java.module.name> + </properties> + + + <dependencies> + <dependency> + <groupId>org.apache.kie.sonataflow</groupId> + <artifactId>sonataflow-quarkus</artifactId> + </dependency> + <dependency> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-resteasy</artifactId> + </dependency> + <dependency> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-resteasy-jackson</artifactId> + </dependency> + <dependency> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-resteasy-client-oidc-filter</artifactId> + </dependency> + <dependency> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-messaging-kafka</artifactId> + <exclusions> + <!-- Overriding kafka-clients to fix a vulnerability. + This exclusion can be removed when Quarkus will contain kafka-clients in at least version 3.9.1. --> + <exclusion> + <groupId>org.apache.kafka</groupId> + <artifactId>kafka-clients</artifactId> + </exclusion> + </exclusions> + </dependency> + <dependency> + <groupId>org.apache.kafka</groupId> + <artifactId>kafka-clients</artifactId> + </dependency> + <dependency> + <groupId>org.apache.kie.sonataflow</groupId> + <artifactId>sonataflow-quarkus-jdbc-token-persistence</artifactId> + <version>${project.version}</version> Review Comment: Same here ########## quarkus/addons/token-exchange/runtime/src/main/java/org/kie/kogito/addons/quarkus/token/exchange/cache/TokenPolicyManager.java: ########## @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.kie.kogito.addons.quarkus.token.exchange.cache; + +import java.util.concurrent.TimeUnit; + +import org.kie.kogito.addons.quarkus.token.exchange.utils.CacheUtils; +import org.kie.kogito.addons.quarkus.token.exchange.utils.ConfigReaderUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.benmanes.caffeine.cache.Expiry; + +public class TokenPolicyManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(TokenPolicyManager.class); + + /** + * Creates an expiry policy that uses each token's actual expiration time + */ + public static Expiry<String, CachedTokens> createTokenExpiryPolicy() { + return new Expiry<String, CachedTokens>() { + @Override + public long expireAfterCreate(String key, CachedTokens value, long currentTime) { + return calculateTimeToExpiration(key, value); + } + + @Override + public long expireAfterUpdate(String key, CachedTokens value, long currentTime, long currentDuration) { + return calculateTimeToExpiration(key, value); + } + + @Override + public long expireAfterRead(String key, CachedTokens value, long currentTime, long currentDuration) { + return currentDuration; // Don't change expiration on read + } + }; + } + + /** + * Calculate time to expiration based on token's actual expiration time minus proactive refresh buffer + */ + private static long calculateTimeToExpiration(String cacheKey, CachedTokens tokens) { + String authName = CacheUtils.extractAuthNameFromCacheKey(cacheKey); + long proactiveRefreshSeconds = ConfigReaderUtils.getProactiveRefreshSeconds(authName); Review Comment: You could also add this limit to make it more robust and avoid incorrect configuration (negative numbers) ```suggestion long proactiveRefreshSeconds = Math.max(0, ConfigReaderUtils.getProactiveRefreshSeconds(authName)); ``` ########## quarkus/extensions/kogito-quarkus-serverless-workflow-jdbc-token-persistence-extension/kogito-quarkus-serverless-workflow-jdbc-token-persistence-deployment/pom.xml: ########## @@ -0,0 +1,83 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <parent> + <artifactId>sonataflow-quarkus-jdbc-token-persistence-extension</artifactId> + <groupId>org.apache.kie.sonataflow</groupId> + <version>999-SNAPSHOT</version> + </parent> + <modelVersion>4.0.0</modelVersion> + + <artifactId>sonataflow-quarkus-jdbc-token-persistence-deployment</artifactId> + <description>SonataFlow :: Quarkus Serverless Workflow JDBC Token Persistence Extension :: Deployment</description> + + <properties> + <java.module.name>org.kie.kogito.serverless.workflow.jdbc.token.persistence.deployment</java.module.name> + </properties> + + <dependencies> + <dependency> + <groupId>org.apache.kie.sonataflow</groupId> + <artifactId>sonataflow-quarkus-jdbc-token-persistence</artifactId> + <version>${project.version}</version> Review Comment: Same here ########## quarkus/addons/token-exchange/runtime/src/main/java/org/kie/kogito/addons/quarkus/token/exchange/cache/TokenEvictionHandler.java: ########## @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.kie.kogito.addons.quarkus.token.exchange.cache; + +import java.util.Collections; +import java.util.concurrent.CompletableFuture; + +import org.kie.kogito.addons.quarkus.token.exchange.utils.CacheUtils; +import org.kie.kogito.addons.quarkus.token.exchange.utils.OidcClientUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.github.benmanes.caffeine.cache.RemovalListener; + +import io.quarkus.oidc.client.OidcClient; +import io.quarkus.oidc.client.Tokens; + +/** + * Handles token eviction events from the Caffeine cache and performs background token refresh operations. + * This class is responsible for managing token lifecycle events including expiration and refresh. + */ +public class TokenEvictionHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(TokenEvictionHandler.class); + public static final String LOG_PREFIX_TOKEN_REFRESH = "Attempting background token refresh"; + public static final String LOG_PREFIX_REFRESH_COMPLETED = "Background refresh completed"; + public static final String LOG_PREFIX_FAILED_TO_REFRESH_TOKEN = "Failed to refresh token"; + + private final TokenCRUD tokenCRUD; + + public TokenEvictionHandler(TokenCRUD tokenCRUD) { + this.tokenCRUD = tokenCRUD; + } + + /** + * Creates a removal listener that can be used with Caffeine cache. + * + * @return A removal listener that handles token eviction events + */ + public RemovalListener<String, CachedTokens> createRemovalListener() { + return (key, value, cause) -> { + if (value == null) + return; + + LOGGER.info("Token cache eviction for cache key '{}' - Cause: {}", key, cause); + onTokenExpired(key, value, cause); + }; + } + + /** + * Callback method called when tokens are evicted from the cache. + * + * @param cacheKey The cache key of the evicted tokens + * @param tokens The evicted token data + * @param cause The reason for eviction (Caffeine's RemovalCause) + */ + private void onTokenExpired(String cacheKey, CachedTokens tokens, RemovalCause cause) { + LOGGER.warn("OAuth2 tokens for cache key '{}' have expired/been evicted: {}", cacheKey, cause); + + // Handle proactive token refresh when cache entry expires (which happens before actual token expiration) + if (cause == RemovalCause.EXPIRED) { + if (tokens.refreshToken() == null) { + LOGGER.warn("OAuth2 tokens for cache key '{}' has no refresh token, the access token cannot be refreshed.", cacheKey); + return; + } + + if (!tokens.isExpiredNow()) { + LOGGER.info("Triggering proactive token refresh for cache key '{}' (token still valid but refresh window reached)", cacheKey); + refreshWithCachedToken(cacheKey, tokens.refreshToken()); + } + } else if (cause == RemovalCause.EXPLICIT) { + LOGGER.info("Deleting OAuth2 tokens with cache key '{}' from persistence system", cacheKey); + tokenCRUD.deleteToken(cacheKey); + } + } + + /** + * Refreshes tokens using a cached refresh token and updates the cache. + * + * @param cacheKey The cache key for the tokens being refreshed + * @param refreshToken The refresh token to use for getting new tokens + */ + private void refreshWithCachedToken(String cacheKey, String refreshToken) { + CompletableFuture.runAsync(() -> { + try { + LOGGER.info("{} - cache key '{}'", LOG_PREFIX_TOKEN_REFRESH, cacheKey); + + String authName = CacheUtils.extractAuthNameFromCacheKey(cacheKey); + OidcClient client = OidcClientUtils.getExchangeTokenClient(authName); + + LOGGER.debug("Refreshing token for cache key '{}' using cached refresh token", cacheKey); + + Tokens refreshedTokens = client.getTokens(Collections.singletonMap("refresh_token", refreshToken)) + .await().indefinitely(); + + tokenCRUD.storeToken(cacheKey, refreshedTokens); + + LOGGER.info("{} - cache key '{}'", LOG_PREFIX_REFRESH_COMPLETED, cacheKey); + } catch (Exception e) { + LOGGER.error("{} - cache key '{}': {}", LOG_PREFIX_FAILED_TO_REFRESH_TOKEN, cacheKey, e.getMessage()); Review Comment: you could pass the complete stacktrace, not only the message ```suggestion LOGGER.error("{} - cache key '{}': {}", LOG_PREFIX_FAILED_TO_REFRESH_TOKEN, cacheKey, e); ``` ########## quarkus/addons/token-exchange/deployment/pom.xml: ########## @@ -0,0 +1,77 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + +--> +<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + <modelVersion>4.0.0</modelVersion> + <parent> + <groupId>org.kie</groupId> + <artifactId>kie-addons-quarkus-token-exchange-parent</artifactId> + <version>999-SNAPSHOT</version> + </parent> + <artifactId>kie-addons-quarkus-token-exchange-deployment</artifactId> + <name>KIE Add-On Token Exchange - Deployment</name> + + <properties> + <java.module.name>org.kie.kogito.quarkus.token.exchange.deployment</java.module.name> + </properties> + + <dependencies> + <dependency> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-arc-deployment</artifactId> + </dependency> + <dependency> + <groupId>io.quarkus</groupId> + <artifactId>quarkus-oidc-client-deployment</artifactId> + <scope>provided</scope> + </dependency> + <dependency> + <groupId>io.quarkiverse.openapi.generator</groupId> + <artifactId>quarkus-openapi-generator-deployment</artifactId> + </dependency> + <dependency> + <groupId>org.kie</groupId> + <artifactId>kogito-addons-quarkus-common-deployment</artifactId> + </dependency> + <dependency> + <groupId>org.kie</groupId> + <artifactId>kie-addons-quarkus-token-exchange</artifactId> + <version>${project.version}</version> Review Comment: you can omit it, I think the `project.version` is redundant here ########## quarkus/addons/token-exchange/runtime/src/main/java/org/kie/kogito/addons/quarkus/token/exchange/cache/TokenEvictionHandler.java: ########## @@ -0,0 +1,120 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.kie.kogito.addons.quarkus.token.exchange.cache; + +import java.util.Collections; +import java.util.concurrent.CompletableFuture; + +import org.kie.kogito.addons.quarkus.token.exchange.utils.CacheUtils; +import org.kie.kogito.addons.quarkus.token.exchange.utils.OidcClientUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.github.benmanes.caffeine.cache.RemovalListener; + +import io.quarkus.oidc.client.OidcClient; +import io.quarkus.oidc.client.Tokens; + +/** + * Handles token eviction events from the Caffeine cache and performs background token refresh operations. + * This class is responsible for managing token lifecycle events including expiration and refresh. + */ +public class TokenEvictionHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(TokenEvictionHandler.class); + public static final String LOG_PREFIX_TOKEN_REFRESH = "Attempting background token refresh"; + public static final String LOG_PREFIX_REFRESH_COMPLETED = "Background refresh completed"; + public static final String LOG_PREFIX_FAILED_TO_REFRESH_TOKEN = "Failed to refresh token"; + + private final TokenCRUD tokenCRUD; + + public TokenEvictionHandler(TokenCRUD tokenCRUD) { + this.tokenCRUD = tokenCRUD; + } + + /** + * Creates a removal listener that can be used with Caffeine cache. + * + * @return A removal listener that handles token eviction events + */ + public RemovalListener<String, CachedTokens> createRemovalListener() { + return (key, value, cause) -> { + if (value == null) + return; + + LOGGER.info("Token cache eviction for cache key '{}' - Cause: {}", key, cause); + onTokenExpired(key, value, cause); + }; + } + + /** + * Callback method called when tokens are evicted from the cache. + * + * @param cacheKey The cache key of the evicted tokens + * @param tokens The evicted token data + * @param cause The reason for eviction (Caffeine's RemovalCause) + */ + private void onTokenExpired(String cacheKey, CachedTokens tokens, RemovalCause cause) { + LOGGER.warn("OAuth2 tokens for cache key '{}' have expired/been evicted: {}", cacheKey, cause); + + // Handle proactive token refresh when cache entry expires (which happens before actual token expiration) + if (cause == RemovalCause.EXPIRED) { + if (tokens.refreshToken() == null) { + LOGGER.warn("OAuth2 tokens for cache key '{}' has no refresh token, the access token cannot be refreshed.", cacheKey); + return; + } + + if (!tokens.isExpiredNow()) { + LOGGER.info("Triggering proactive token refresh for cache key '{}' (token still valid but refresh window reached)", cacheKey); + refreshWithCachedToken(cacheKey, tokens.refreshToken()); + } + } else if (cause == RemovalCause.EXPLICIT) { + LOGGER.info("Deleting OAuth2 tokens with cache key '{}' from persistence system", cacheKey); + tokenCRUD.deleteToken(cacheKey); + } + } + + /** + * Refreshes tokens using a cached refresh token and updates the cache. + * + * @param cacheKey The cache key for the tokens being refreshed + * @param refreshToken The refresh token to use for getting new tokens + */ + private void refreshWithCachedToken(String cacheKey, String refreshToken) { + CompletableFuture.runAsync(() -> { Review Comment: you're using here the default Executor. However for more control and configurability you could define your own executor: `private final ExecutorService tokenRefreshExecutor = Executors.newFixedThreadPool(NUMBER_THREADS);` and pass it to the runAsync: ``` CompletableFuture.runAsync(() -> { .... }, tokenRefreshExecutor); ``` ########## quarkus/extensions/kogito-quarkus-serverless-workflow-extension/kogito-quarkus-serverless-workflow-integration-test/src/test/java/org/kie/kogito/quarkus/workflows/TokenExchangeIT.java: ########## @@ -18,41 +18,203 @@ */ package org.kie.kogito.quarkus.workflows; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusIntegrationTest; import io.restassured.path.json.JsonPath; import jakarta.ws.rs.core.HttpHeaders; +import static io.restassured.RestAssured.given; +import static org.kie.kogito.addons.quarkus.token.exchange.OpenApiCustomCredentialProvider.LOG_PREFIX_COMPLETED_TOKEN_EXCHANGE; +import static org.kie.kogito.addons.quarkus.token.exchange.OpenApiCustomCredentialProvider.LOG_PREFIX_FAILED_TOKEN_EXCHANGE; +import static org.kie.kogito.addons.quarkus.token.exchange.OpenApiCustomCredentialProvider.LOG_PREFIX_STARTING_TOKEN_EXCHANGE; +import static org.kie.kogito.addons.quarkus.token.exchange.cache.TokenEvictionHandler.LOG_PREFIX_FAILED_TO_REFRESH_TOKEN; +import static org.kie.kogito.addons.quarkus.token.exchange.cache.TokenEvictionHandler.LOG_PREFIX_REFRESH_COMPLETED; +import static org.kie.kogito.addons.quarkus.token.exchange.cache.TokenEvictionHandler.LOG_PREFIX_TOKEN_REFRESH; +import static org.kie.kogito.addons.quarkus.token.exchange.persistence.TokenDataStoreImpl.LOG_PREFIX_USED_REPOSITORY; import static org.kie.kogito.quarkus.workflows.ExternalServiceMock.SUCCESSFUL_QUERY; import static org.kie.kogito.quarkus.workflows.TokenExchangeExternalServicesMock.BASE_AND_PROPAGATED_AUTHORIZATION_TOKEN; +import static org.kie.kogito.test.utils.ProcessInstancesRESTTestUtils.assertProcessInstanceNotExists; import static org.kie.kogito.test.utils.ProcessInstancesRESTTestUtils.newProcessInstance; @QuarkusTestResource(TokenExchangeExternalServicesMock.class) @QuarkusTestResource(KeycloakServiceMock.class) @QuarkusIntegrationTest class TokenExchangeIT { + private static final Logger LOGGER = LoggerFactory.getLogger(TokenExchangeIT.class); @Test - void tokenExchange() { - // start a new process instance by sending the post query and collect the process instance id. + void tokenExchange() throws IOException { + LOGGER.info("Testing token exchange caching behavior - expecting 3 external service calls but only 2 token exchanges"); + + // Get the Quarkus log file path (configured in application.properties) + Path logFile = getQuarkusLogFile(); + + // Clear the log file to start fresh + if (Files.exists(logFile)) { + Files.write(logFile, new byte[0]); // Clear the file + } + + // Start a new process instance String processInput = buildProcessInput(SUCCESSFUL_QUERY); Map<String, String> headers = new HashMap<>(); - // prepare the headers to pass to the token_propagation SW. - // service token-propagation-external-service1 and token-propagation-external-service2 will receive the AUTHORIZATION_TOKEN headers.put(HttpHeaders.AUTHORIZATION, BASE_AND_PROPAGATED_AUTHORIZATION_TOKEN); JsonPath jsonPath = newProcessInstance("/token_exchange", processInput, headers); - Assertions.assertThat(jsonPath.getString("id")).isNotBlank(); + String processInstanceId = jsonPath.getString("id"); + Assertions.assertThat(processInstanceId).isNotBlank(); + + // Wait for the process to complete - it should take approximately 11+ seconds + // due to the 1s delay + 10s delay in the workflow + long startTime = System.currentTimeMillis(); + waitForProcessCompletion(processInstanceId, Duration.ofSeconds(25)); + long endTime = System.currentTimeMillis(); + + LOGGER.info("Process completed in {} seconds", (endTime - startTime) / 1000.0); + + // Verify the process completed successfully (404 means it completed and was cleaned up) + assertProcessInstanceNotExists("/token_exchange/{id}", processInstanceId); + + // Verify caching behavior by checking WireMock requests + validateCachingBehavior(); + validateOAuth2LogsFromFile(logFile); + } + + /* + * @Test + * void tokenExchangeMissingAuthorizationHeader() { + * // start a new process instance by sending the post query and collect the process instance id. + * String processInput = buildProcessInput(SUCCESSFUL_QUERY); + * Map<String, String> headers = new HashMap<>(); + * + * JsonPath jsonPath = newProcessInstance("/token_exchange", processInput, headers); + * Assertions.assertThat(jsonPath.getString("id")).isNotBlank(); + * getProcessInstance(jsonPath.getString("id")); + * } + */ + Review Comment: Remove completely if it's not used ########## quarkus/addons/token-exchange/README.md: ########## @@ -0,0 +1,79 @@ +<!-- + Licensed to the Apache Software Foundation (ASF) under one + or more contributor license agreements. See the NOTICE file + distributed with this work for additional information + regarding copyright ownership. The ASF licenses this file + to you under the Apache License, Version 2.0 (the + "License"); you may not use this file except in compliance + with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, + software distributed under the License is distributed on an + "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + KIND, either express or implied. See the License for the + specific language governing permissions and limitations + under the License. + --> +# KIE Add-On Token Exchange + +This add-on provides OAuth2 token exchange functionality for Kogito Quarkus applications with caching and database persistence. + +## Overview + +The token-exchange add-on implements OAuth2 Token Exchange functionality, allowing applications to exchange one token for another. This is useful for scenarios where you need to: + +- Exchange an access token for a token with different scope or audience +- Impersonate users or services +- Implement token chaining in microservice architectures + +The addon includes: +- **Caffeine-based caching** for token storage with per-token expiration +- **Database persistence** for token durability +- **Proactive token refresh** to minimize authentication delays +- **OpenAPI integration** for automated credential management + +## Usage + +Add the dependency to your `pom.xml`: + +```xml +<dependency> + <groupId>org.kie</groupId> + <artifactId>kie-addons-quarkus-token-exchange</artifactId> +</dependency> +``` + +## Components + +### Runtime Module +- **OpenApiCustomCredentialProvider**: Main credential provider with token exchange and caching +- **Cache**: + - `CachedTokens`: Token wrapper with expiration tracking + - `TokenPolicyManager`: Expiry policy for per-token cache management + - `TokenEvictionHandler`: Handles cache eviction and proactive refresh +- **Persistence**: + - `DatabaseTokenDataStore`: Database-backed token storage + - `TokenCacheRepository`: Repository interface for token CRUD operations + - `TokenCacheRecord`: JPA entity for token storage +- **Utilities**: + - `OidcClientUtils`: OIDC client utilities for token exchange + - `CacheUtils`: Cache key management utilities + - `ConfigReaderUtils`: Configuration reading utilities + - `JwtTokenUtils`: JWT token parsing utilities + +### Deployment Module +- **TokenExchangeProcessor**: Quarkus deployment processor for extension configuration + +## Configuration + +Configure token exchange using properties like: Review Comment: Perhaps you could add all the configuration properties, or clarify that this is just an example (typical configuration properties) ########## quarkus/addons/token-exchange/runtime/src/main/java/org/kie/kogito/addons/quarkus/token/exchange/utils/JwtTokenUtils.java: ########## @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.kie.kogito.addons.quarkus.token.exchange.utils; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Utility class for JWT token parsing operations. + */ +public final class JwtTokenUtils { + private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenUtils.class); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private JwtTokenUtils() { + // Utility class - prevent instantiation + } + + /** + * Parses the expiration time from a JWT access token. + * + * @param accessToken The JWT access token + * @return The expiration time as Unix timestamp, or default expiration if parsing fails + */ + public static long parseTokenExpiration(String accessToken) { + try { + String[] parts = accessToken.split("\\."); + if (parts.length != 3) { + LOGGER.warn("Invalid JWT token format while parsing token expiration, will use default expiration of 1 hour"); + return TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + Duration.of(1, ChronoUnit.HOURS).getSeconds(); + } + + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); + JsonNode json = MAPPER.readTree(payload); + + if (json.has("exp")) { + return json.get("exp").asLong(); + } + + } catch (Exception e) { + LOGGER.warn("Failed to parse token expiration: {}", e.getMessage()); + } + + // Default expiration if parsing fails + return TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + Duration.of(1, ChronoUnit.HOURS).getSeconds(); Review Comment: Better readibility: ```suggestion return TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + Duration.ofHours(1).getSeconds(); ``` ########## quarkus/extensions/kogito-quarkus-serverless-workflow-jdbc-token-persistence-extension/kogito-quarkus-serverless-workflow-jdbc-token-persistence-integration-test/src/test/java/org/kie/kogito/quarkus/token/persistence/workflows/TokenExchangeIT.java: ########## @@ -0,0 +1,222 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.kie.kogito.quarkus.token.persistence.workflows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.junit.QuarkusIntegrationTest; +import io.restassured.path.json.JsonPath; + +import jakarta.ws.rs.core.HttpHeaders; + +import static io.restassured.RestAssured.given; +import static org.kie.kogito.addons.quarkus.token.exchange.OpenApiCustomCredentialProvider.LOG_PREFIX_COMPLETED_TOKEN_EXCHANGE; +import static org.kie.kogito.addons.quarkus.token.exchange.OpenApiCustomCredentialProvider.LOG_PREFIX_FAILED_TOKEN_EXCHANGE; +import static org.kie.kogito.addons.quarkus.token.exchange.OpenApiCustomCredentialProvider.LOG_PREFIX_STARTING_TOKEN_EXCHANGE; +import static org.kie.kogito.addons.quarkus.token.exchange.cache.TokenEvictionHandler.LOG_PREFIX_FAILED_TO_REFRESH_TOKEN; +import static org.kie.kogito.addons.quarkus.token.exchange.cache.TokenEvictionHandler.LOG_PREFIX_REFRESH_COMPLETED; +import static org.kie.kogito.addons.quarkus.token.exchange.cache.TokenEvictionHandler.LOG_PREFIX_TOKEN_REFRESH; +import static org.kie.kogito.addons.quarkus.token.exchange.persistence.TokenDataStoreImpl.LOG_PREFIX_USED_REPOSITORY; +import static org.kie.kogito.quarkus.token.persistence.workflows.ExternalServiceMock.SUCCESSFUL_QUERY; +import static org.kie.kogito.quarkus.token.persistence.workflows.TokenExchangeExternalServicesMock.BASE_AND_PROPAGATED_AUTHORIZATION_TOKEN; +import static org.kie.kogito.test.utils.ProcessInstancesRESTTestUtils.assertProcessInstanceNotExists; +import static org.kie.kogito.test.utils.ProcessInstancesRESTTestUtils.newProcessInstance; + +@QuarkusTestResource(TokenExchangeExternalServicesMock.class) +@QuarkusTestResource(KeycloakServiceMock.class) +@QuarkusIntegrationTest +class TokenExchangeIT { + private static final Logger LOGGER = LoggerFactory.getLogger(TokenExchangeIT.class); + + @Test + void tokenExchange() throws IOException { + LOGGER.info("Testing token exchange caching behavior - expecting 3 external service calls but only 2 token exchanges"); + + // Get the Quarkus log file path (configured in application.properties) + Path logFile = getQuarkusLogFile(); + + // Clear the log file to start fresh + if (Files.exists(logFile)) { + Files.write(logFile, new byte[0]); // Clear the file + } + + // Start a new process instance + String processInput = buildProcessInput(SUCCESSFUL_QUERY); + Map<String, String> headers = new HashMap<>(); + headers.put(HttpHeaders.AUTHORIZATION, BASE_AND_PROPAGATED_AUTHORIZATION_TOKEN); + + JsonPath jsonPath = newProcessInstance("/token_exchange", processInput, headers); + String processInstanceId = jsonPath.getString("id"); + Assertions.assertThat(processInstanceId).isNotBlank(); + + // Wait for the process to complete - it should take approximately 11+ seconds + // due to the 1s delay + 10s delay in the workflow + long startTime = System.currentTimeMillis(); + waitForProcessCompletion(processInstanceId, Duration.ofSeconds(25)); + long endTime = System.currentTimeMillis(); + + LOGGER.info("Process completed in {} seconds", (endTime - startTime) / 1000.0); + + // Verify the process completed successfully (404 means it completed and was cleaned up) + assertProcessInstanceNotExists("/token_exchange/{id}", processInstanceId); + + // Verify caching behavior by checking WireMock requests + validateCachingBehavior(); + validateOAuth2LogsFromFile(logFile); + } + + /* + * @Test + * void tokenExchangeMissingAuthorizationHeader() { + * // start a new process instance by sending the post query and collect the process instance id. + * String processInput = buildProcessInput(SUCCESSFUL_QUERY); + * Map<String, String> headers = new HashMap<>(); + * + * JsonPath jsonPath = newProcessInstance("/token_exchange", processInput, headers); + * Assertions.assertThat(jsonPath.getString("id")).isNotBlank(); + * getProcessInstance(jsonPath.getString("id")); + * } + */ Review Comment: if not used, it could be deleted for clear. ########## quarkus/addons/token-exchange/runtime/src/main/java/org/kie/kogito/addons/quarkus/token/exchange/utils/JwtTokenUtils.java: ########## @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.kie.kogito.addons.quarkus.token.exchange.utils; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Utility class for JWT token parsing operations. + */ +public final class JwtTokenUtils { + private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenUtils.class); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private JwtTokenUtils() { + // Utility class - prevent instantiation + } + + /** + * Parses the expiration time from a JWT access token. + * + * @param accessToken The JWT access token + * @return The expiration time as Unix timestamp, or default expiration if parsing fails + */ + public static long parseTokenExpiration(String accessToken) { + try { + String[] parts = accessToken.split("\\."); + if (parts.length != 3) { + LOGGER.warn("Invalid JWT token format while parsing token expiration, will use default expiration of 1 hour"); + return TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + Duration.of(1, ChronoUnit.HOURS).getSeconds(); + } + + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); + JsonNode json = MAPPER.readTree(payload); + + if (json.has("exp")) { + return json.get("exp").asLong(); Review Comment: if "exp" is zero or negative, perhaps you could return default expiration: `currentTimeSeconds + Duration.ofHours(1).getSeconds();` ########## quarkus/addons/token-exchange/runtime/src/main/java/org/kie/kogito/addons/quarkus/token/exchange/utils/JwtTokenUtils.java: ########## @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.kie.kogito.addons.quarkus.token.exchange.utils; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Utility class for JWT token parsing operations. + */ +public final class JwtTokenUtils { + private static final Logger LOGGER = LoggerFactory.getLogger(JwtTokenUtils.class); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private JwtTokenUtils() { + // Utility class - prevent instantiation + } + + /** + * Parses the expiration time from a JWT access token. + * + * @param accessToken The JWT access token + * @return The expiration time as Unix timestamp, or default expiration if parsing fails + */ + public static long parseTokenExpiration(String accessToken) { + try { + String[] parts = accessToken.split("\\."); + if (parts.length != 3) { + LOGGER.warn("Invalid JWT token format while parsing token expiration, will use default expiration of 1 hour"); + return TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + Duration.of(1, ChronoUnit.HOURS).getSeconds(); + } + + String payload = new String(Base64.getUrlDecoder().decode(parts[1])); Review Comment: Default charset to UTF-8 may be important across all environments: ```suggestion String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); ``` -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
