From c3440a4c95fc111e1ea17af261a1664511823c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Vav=C5=99=C3=ADk?= Date: Mon, 18 Nov 2024 12:00:00 +0100 Subject: [PATCH] feat(oidc): Add OIDC Redis Token State Manager extension --- bom/application/pom.xml | 10 ++ devtools/bom-descriptor-json/pom.xml | 13 +++ docs/pom.xml | 13 +++ ...ecurity-oidc-code-flow-authentication.adoc | 32 ++++++ .../deployment/pom.xml | 98 +++++++++++++++++++ ...OidcRedisTokenStateManagerBuildConfig.java | 31 ++++++ .../OidcRedisTokenStateManagerProcessor.java | 67 +++++++++++++ .../AbstractRedisTokenStateManagerTest.java | 93 ++++++++++++++++++ .../DefaultDsRedisTokenStateManagerTest.java | 12 +++ .../NamedDsRedisTokenStateManagerTest.java | 17 ++++ .../manager/deployment/ProtectedResource.java | 31 ++++++ .../manager/deployment/PublicResource.java | 35 +++++++ .../deployment/UnprotectedResource.java | 13 +++ .../oidc-redis-token-state-manager/pom.xml | 20 ++++ .../runtime/pom.xml | 50 ++++++++++ .../runtime/OidcRedisTokenStateManager.java | 94 ++++++++++++++++++ .../OidcRedisTokenStateManagerRecorder.java | 31 ++++++ .../resources/META-INF/quarkus-extension.yaml | 17 ++++ .../runtime/CodeAuthenticationMechanism.java | 3 +- extensions/pom.xml | 1 + 20 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 extensions/oidc-redis-token-state-manager/deployment/pom.xml create mode 100644 extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerBuildConfig.java create mode 100644 extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerProcessor.java create mode 100644 extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/AbstractRedisTokenStateManagerTest.java create mode 100644 extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/DefaultDsRedisTokenStateManagerTest.java create mode 100644 extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/NamedDsRedisTokenStateManagerTest.java create mode 100644 extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/ProtectedResource.java create mode 100644 extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/PublicResource.java create mode 100644 extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/UnprotectedResource.java create mode 100644 extensions/oidc-redis-token-state-manager/pom.xml create mode 100644 extensions/oidc-redis-token-state-manager/runtime/pom.xml create mode 100644 extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManager.java create mode 100644 extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerRecorder.java create mode 100644 extensions/oidc-redis-token-state-manager/runtime/src/main/resources/META-INF/quarkus-extension.yaml diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 8414ce058191b..9fc734b20e816 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -1024,6 +1024,16 @@ quarkus-oidc-db-token-state-manager-deployment ${project.version} + + io.quarkus + quarkus-oidc-redis-token-state-manager + ${project.version} + + + io.quarkus + quarkus-oidc-redis-token-state-manager-deployment + ${project.version} + io.quarkus quarkus-rest-client-oidc-token-propagation diff --git a/devtools/bom-descriptor-json/pom.xml b/devtools/bom-descriptor-json/pom.xml index 59f92096d0541..95fd35896d6c3 100644 --- a/devtools/bom-descriptor-json/pom.xml +++ b/devtools/bom-descriptor-json/pom.xml @@ -1838,6 +1838,19 @@ + + io.quarkus + quarkus-oidc-redis-token-state-manager + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-openshift diff --git a/docs/pom.xml b/docs/pom.xml index 29dd7a55c0a9f..d5e96e352d096 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -1849,6 +1849,19 @@ + + io.quarkus + quarkus-oidc-redis-token-state-manager-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-openshift-deployment diff --git a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc index 3dbd97e8eb429..bdc28766e65d4 100644 --- a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc @@ -1083,6 +1083,38 @@ For more information, refer to the xref:hibernate-orm.adoc[Hibernate ORM] guide. <2> You can choose a column length depending on the length of your tokens. endif::no-quarkus-oidc-db-token-state-manager[] +[[redis-token-state-manager]] +==== Redis TokenStateManager + +Another approach for a stateful token storage strategy is a custom `TokenStateManager` provided by Quarkus to have your application store tokens in a Redis cache. +If you decided to use the OIDC Redis Token State Manager, you must add the following dependency: + +[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] +.pom.xml +---- + + io.quarkus + quarkus-oidc-redis-token-state-manager + +---- + +[source,gradle,role="secondary asciidoc-tabs-target-sync-gradle"] +.build.gradle +---- +implementation("io.quarkus:quarkus-oidc-redis-token-state-manager") +---- + +Quarkus stores tokens in the default Redis client. +If you prefer to use different Redis client, you can configure it like in the example below: + +[source, properties] +---- +quarkus.oidc.redis-token-state-manager.redis-client-name=my-redis-client <1> +---- +<1> The `my-redis-client` name must correspond to the Redis client config key specified with `quarkus.redis.my-redis-client.*` configuration properties. + +Please refer to the xref:redis-reference.adoc[Quarkus Redis Client reference] for information how to configure the Redis client. + === Logout and expiration There are two main ways for the authentication information to expire: the tokens expired and were not renewed or an explicit logout operation was triggered. diff --git a/extensions/oidc-redis-token-state-manager/deployment/pom.xml b/extensions/oidc-redis-token-state-manager/deployment/pom.xml new file mode 100644 index 0000000000000..989ee4ea2aa3f --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/deployment/pom.xml @@ -0,0 +1,98 @@ + + + + quarkus-oidc-redis-token-state-manager-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-oidc-redis-token-state-manager-deployment + Quarkus - OpenID Connect Redis Token State Manager - Deployment + + + + io.quarkus + quarkus-oidc-redis-token-state-manager + + + io.quarkus + quarkus-oidc-deployment + + + io.quarkus + quarkus-redis-client-deployment + + + + io.quarkus + quarkus-rest-deployment + test + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + + + org.htmlunit + htmlunit + test + + + + + + + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + maven-surefire-plugin + + true + + + + + + + test-containers + + + test-containers + + + + + + maven-surefire-plugin + + false + + + + + + + diff --git a/extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerBuildConfig.java b/extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerBuildConfig.java new file mode 100644 index 0000000000000..4fb418db1df78 --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerBuildConfig.java @@ -0,0 +1,31 @@ +package io.quarkus.oidc.redis.token.state.manager.deployment; + +import io.quarkus.redis.runtime.client.config.RedisConfig; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +/** + * OIDC Redis Token State Manager build-time configuration. + */ +@ConfigRoot +@ConfigMapping(prefix = "quarkus.oidc.redis-token-state-manager") +public interface OidcRedisTokenStateManagerBuildConfig { + + /** + * Enables this extension. + * Set to 'false' if this extension should be disabled. + */ + @WithDefault("true") + boolean enabled(); + + /** + * Selects Redis client used to store the OIDC token state. + * The default Redis client is used if this property is not configured. + * Used Redis datasource must only be accessible by trusted parties, + * because Quarkus will not encrypt tokens before storing them. + */ + @WithDefault(RedisConfig.DEFAULT_CLIENT_NAME) + String redisClientName(); + +} diff --git a/extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerProcessor.java b/extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerProcessor.java new file mode 100644 index 0000000000000..07eb30af436d9 --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/deployment/src/main/java/io/quarkus/oidc/redis/token/state/manager/deployment/OidcRedisTokenStateManagerProcessor.java @@ -0,0 +1,67 @@ +package io.quarkus.oidc.redis.token.state.manager.deployment; + +import java.util.function.BooleanSupplier; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.Type; + +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.oidc.TokenStateManager; +import io.quarkus.oidc.redis.token.state.manager.runtime.OidcRedisTokenStateManagerRecorder; +import io.quarkus.redis.client.RedisClientName; +import io.quarkus.redis.datasource.ReactiveRedisDataSource; +import io.quarkus.redis.deployment.client.RequestedRedisClientBuildItem; +import io.quarkus.redis.runtime.client.config.RedisConfig; + +@BuildSteps(onlyIf = OidcRedisTokenStateManagerProcessor.IsEnabled.class) +public class OidcRedisTokenStateManagerProcessor { + + @BuildStep + RequestedRedisClientBuildItem requestRedisClient(OidcRedisTokenStateManagerBuildConfig buildConfig) { + return new RequestedRedisClientBuildItem(buildConfig.redisClientName()); + } + + @Record(ExecutionTime.STATIC_INIT) + @BuildStep + SyntheticBeanBuildItem createTokenStateManager(OidcRedisTokenStateManagerRecorder recorder, + OidcRedisTokenStateManagerBuildConfig buildConfig) { + var redisClientName = buildConfig.redisClientName(); + var beanConfigurator = SyntheticBeanBuildItem.configure(TokenStateManager.class) + .priority(1) + .alternative(true) + .unremovable() + .scope(ApplicationScoped.class); + if (RedisConfig.isDefaultClient(redisClientName)) { + beanConfigurator + .createWith(recorder.createTokenStateManager(null)) + .addInjectionPoint(Type.create(ReactiveRedisDataSource.class)); + } else { + beanConfigurator + .createWith(recorder.createTokenStateManager(redisClientName)) + .addInjectionPoint(Type.create(ReactiveRedisDataSource.class), + AnnotationInstance.builder(RedisClientName.class).value(redisClientName).build()); + } + return beanConfigurator.done(); + } + + static final class IsEnabled implements BooleanSupplier { + + private final boolean enabled; + + IsEnabled(OidcRedisTokenStateManagerBuildConfig buildConfig) { + this.enabled = buildConfig.enabled(); + } + + @Override + public boolean getAsBoolean() { + return enabled; + } + } + +} diff --git a/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/AbstractRedisTokenStateManagerTest.java b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/AbstractRedisTokenStateManagerTest.java new file mode 100644 index 0000000000000..b7f74006d03ef --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/AbstractRedisTokenStateManagerTest.java @@ -0,0 +1,93 @@ +package io.quarkus.oidc.redis.token.state.manager.deployment; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.io.IOException; +import java.net.URI; +import java.net.URL; + +import org.hamcrest.Matchers; +import org.htmlunit.SilentCssErrorHandler; +import org.htmlunit.TextPage; +import org.htmlunit.WebClient; +import org.htmlunit.WebRequest; +import org.htmlunit.WebResponse; +import org.htmlunit.html.HtmlForm; +import org.htmlunit.html.HtmlPage; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.restassured.RestAssured; + +public abstract class AbstractRedisTokenStateManagerTest { + + protected static QuarkusUnitTest createQuarkusUnitTest(String... extraProps) { + return new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(ProtectedResource.class, UnprotectedResource.class, PublicResource.class) + .addAsResource(new StringAsset(""" + quarkus.oidc.client-id=quarkus-web-app + quarkus.oidc.application-type=web-app + quarkus.oidc.logout.path=/protected/logout + quarkus.log.category."org.htmlunit.javascript.host.css.CSSStyleSheet".level=FATAL + quarkus.log.category."org.htmlunit.css".level=FATAL + """ + String.join(System.lineSeparator(), extraProps)), "application.properties")); + } + + @TestHTTPResource + URL url; + + @Test + public void testCodeFlow() throws IOException { + try (final WebClient webClient = createWebClient()) { + + TextPage textPage = webClient.getPage(url.toString() + "unprotected"); + assertEquals("unprotected", textPage.getContent()); + + HtmlPage page; + page = webClient.getPage(url.toString() + "protected"); + + assertEquals("Sign in to quarkus", page.getTitleText()); + + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + textPage = loginForm.getInputByName("login").click(); + + assertEquals("alice", textPage.getContent()); + + assertTokenStateCount(1); + + webClient.getOptions().setRedirectEnabled(false); + WebResponse webResponse = webClient + .loadWebResponse(new WebRequest(URI.create(url.toString() + "protected/logout").toURL())); + assertEquals(302, webResponse.getStatusCode()); + assertNull(webClient.getCookieManager().getCookie("q_session")); + + webClient.getCookieManager().clearCookies(); + + assertTokenStateCount(0); + } + } + + protected static void assertTokenStateCount(Integer tokenStateCount) { + RestAssured + .given() + .get("public/oidc-token-states-count") + .then() + .statusCode(200) + .body(Matchers.is(tokenStateCount.toString())); + } + + protected static WebClient createWebClient() { + WebClient webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + return webClient; + } + +} diff --git a/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/DefaultDsRedisTokenStateManagerTest.java b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/DefaultDsRedisTokenStateManagerTest.java new file mode 100644 index 0000000000000..8348a9250192c --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/DefaultDsRedisTokenStateManagerTest.java @@ -0,0 +1,12 @@ +package io.quarkus.oidc.redis.token.state.manager.deployment; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class DefaultDsRedisTokenStateManagerTest extends AbstractRedisTokenStateManagerTest { + + @RegisterExtension + static final QuarkusUnitTest test = createQuarkusUnitTest(); + +} diff --git a/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/NamedDsRedisTokenStateManagerTest.java b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/NamedDsRedisTokenStateManagerTest.java new file mode 100644 index 0000000000000..0e34ce125f515 --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/NamedDsRedisTokenStateManagerTest.java @@ -0,0 +1,17 @@ +package io.quarkus.oidc.redis.token.state.manager.deployment; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class NamedDsRedisTokenStateManagerTest extends AbstractRedisTokenStateManagerTest { + + @RegisterExtension + static final QuarkusUnitTest test = createQuarkusUnitTest( + "quarkus.oidc.redis-token-state-manager.redis-client-name=named-1", + "quarkus.redis.devservices.enabled=false", + "quarkus.redis.named-1.client-name=named-1", + "test.redis-client-name=named-1", + "quarkus.redis.named-1.devservices.enabled=true"); + +} diff --git a/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/ProtectedResource.java b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/ProtectedResource.java new file mode 100644 index 0000000000000..1b50fd523b582 --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/ProtectedResource.java @@ -0,0 +1,31 @@ +package io.quarkus.oidc.redis.token.state.manager.deployment; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.oidc.IdToken; +import io.quarkus.security.Authenticated; + +@Path("/protected") +@Authenticated +public class ProtectedResource { + + @Inject + @IdToken + JsonWebToken idToken; + + @GET + public String getName() { + return idToken.getName(); + } + + @GET + @Path("logout") + public void logout() { + throw new RuntimeException("Logout must be handled by CodeAuthenticationMechanism"); + } + +} diff --git a/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/PublicResource.java b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/PublicResource.java new file mode 100644 index 0000000000000..260349027e5d8 --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/PublicResource.java @@ -0,0 +1,35 @@ +package io.quarkus.oidc.redis.token.state.manager.deployment; + +import java.util.Optional; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.quarkus.arc.Arc; +import io.quarkus.redis.client.RedisClientName; +import io.quarkus.redis.datasource.ReactiveRedisDataSource; +import io.smallrye.mutiny.Uni; + +@Path("/public") +public class PublicResource { + + private final ReactiveRedisDataSource dataSource; + + PublicResource(@ConfigProperty(name = "test.redis-client-name") Optional clientName) { + if (clientName.isEmpty()) { + dataSource = Arc.container().select(ReactiveRedisDataSource.class).get(); + } else { + dataSource = Arc.container() + .select(ReactiveRedisDataSource.class, RedisClientName.Literal.of(clientName.get())).get(); + } + } + + @Path("oidc-token-states-count") + @GET + public Uni countOidcTokenStates() { + return dataSource.key().keys("oidc:token:*").map(l -> (long) l.size()); + } + +} diff --git a/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/UnprotectedResource.java b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/UnprotectedResource.java new file mode 100644 index 0000000000000..9215a4ca0edc1 --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/deployment/src/test/java/io/quarkus/oidc/redis/token/state/manager/deployment/UnprotectedResource.java @@ -0,0 +1,13 @@ +package io.quarkus.oidc.redis.token.state.manager.deployment; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@Path("/unprotected") +public class UnprotectedResource { + + @GET + public String getName() { + return "unprotected"; + } +} diff --git a/extensions/oidc-redis-token-state-manager/pom.xml b/extensions/oidc-redis-token-state-manager/pom.xml new file mode 100644 index 0000000000000..35b8f5dd20976 --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/pom.xml @@ -0,0 +1,20 @@ + + + + quarkus-extensions-parent + io.quarkus + 999-SNAPSHOT + ../pom.xml + + 4.0.0 + + quarkus-oidc-redis-token-state-manager-parent + Quarkus - OpenID Connect Redis Token State Manager - Parent + pom + + deployment + runtime + + diff --git a/extensions/oidc-redis-token-state-manager/runtime/pom.xml b/extensions/oidc-redis-token-state-manager/runtime/pom.xml new file mode 100644 index 0000000000000..85176f5f41b1a --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/runtime/pom.xml @@ -0,0 +1,50 @@ + + + + quarkus-oidc-redis-token-state-manager-parent + io.quarkus + 999-SNAPSHOT + + 4.0.0 + + quarkus-oidc-redis-token-state-manager + Quarkus - OpenID Connect Redis Token State Manager - Runtime + Store an OpenID Connect token state in a Redis cache datasource + + + io.quarkus + quarkus-oidc + + + io.quarkus + quarkus-redis-client + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + + + diff --git a/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManager.java b/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManager.java new file mode 100644 index 0000000000000..08d4c0fe0a664 --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManager.java @@ -0,0 +1,94 @@ +package io.quarkus.oidc.redis.token.state.manager.runtime; + +import static io.quarkus.oidc.runtime.CodeAuthenticationMechanism.SESSION_MAX_AGE_PARAM; + +import java.time.Instant; +import java.util.UUID; +import java.util.function.Function; + +import io.quarkus.oidc.AuthorizationCodeTokens; +import io.quarkus.oidc.OidcRequestContext; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TokenStateManager; +import io.quarkus.redis.datasource.ReactiveRedisDataSource; +import io.quarkus.redis.datasource.value.SetArgs; +import io.quarkus.security.AuthenticationCompletionException; +import io.quarkus.security.AuthenticationFailedException; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +final class OidcRedisTokenStateManager implements TokenStateManager { + + private static final String REDIS_KEY_PREFIX = "oidc:token:"; + private final ReactiveRedisDataSource dataSource; + + OidcRedisTokenStateManager(ReactiveRedisDataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public Uni createTokenState(RoutingContext event, OidcTenantConfig oidcConfig, AuthorizationCodeTokens tokens, + OidcRequestContext requestContext) { + return createTokenState(AuthorizationCodeTokensRecord.of(tokens), 0, newSetArgs(event)) + .onFailure().transform(AuthenticationFailedException::new); + } + + @Override + public Uni getTokens(RoutingContext routingContext, OidcTenantConfig oidcConfig, String tokenState, + OidcRequestContext requestContext) { + return dataSource.value(AuthorizationCodeTokensRecord.class).get(toTokenKey(tokenState)) + .onItem().ifNotNull().transform(AuthorizationCodeTokensRecord::toTokens) + .onFailure().transform(AuthenticationCompletionException::new); + } + + @Override + public Uni deleteTokens(RoutingContext routingContext, OidcTenantConfig oidcConfig, String tokenState, + OidcRequestContext requestContext) { + return dataSource.key(String.class).del(toTokenKey(tokenState)).onFailure().recoverWithNull().replaceWithVoid(); + } + + private Uni createTokenState(AuthorizationCodeTokensRecord tokens, int attemptCount, SetArgs setArgs) { + if (attemptCount >= 3) { + return Uni.createFrom().failure( + new RuntimeException("Failed to store OIDC token state in Redis as generated key already existed")); + } + final String tokenState = UUID.randomUUID().toString(); + return dataSource + .value(AuthorizationCodeTokensRecord.class) + .setGet(toTokenKey(tokenState), tokens, setArgs) + .flatMap(new Function>() { + @Override + public Uni apply(AuthorizationCodeTokensRecord previousValue) { + if (previousValue == null) { + // value stored + return Uni.createFrom().item(tokenState); + } + // record key already existed, let's try again + return createTokenState(tokens, attemptCount + 1, setArgs); + } + }); + } + + private static String toTokenKey(String tokenState) { + return REDIS_KEY_PREFIX + tokenState; + } + + private static SetArgs newSetArgs(RoutingContext event) { + return new SetArgs().nx().exAt(expiresAt(event)); + } + + private static Instant expiresAt(RoutingContext event) { + return Instant.now().plusSeconds(event. get(SESSION_MAX_AGE_PARAM)); + } + + record AuthorizationCodeTokensRecord(String idToken, String accessToken, String refreshToken) { + + private static AuthorizationCodeTokensRecord of(AuthorizationCodeTokens tokens) { + return new AuthorizationCodeTokensRecord(tokens.getIdToken(), tokens.getAccessToken(), tokens.getRefreshToken()); + } + + private AuthorizationCodeTokens toTokens() { + return new AuthorizationCodeTokens(idToken, accessToken, refreshToken); + } + } +} diff --git a/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerRecorder.java b/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerRecorder.java new file mode 100644 index 0000000000000..4f959cced65a6 --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/runtime/src/main/java/io/quarkus/oidc/redis/token/state/manager/runtime/OidcRedisTokenStateManagerRecorder.java @@ -0,0 +1,31 @@ +package io.quarkus.oidc.redis.token.state.manager.runtime; + +import java.util.function.Function; + +import io.quarkus.arc.SyntheticCreationalContext; +import io.quarkus.oidc.TokenStateManager; +import io.quarkus.redis.client.RedisClientName; +import io.quarkus.redis.datasource.ReactiveRedisDataSource; +import io.quarkus.runtime.annotations.Recorder; + +@Recorder +public class OidcRedisTokenStateManagerRecorder { + + public Function, TokenStateManager> createTokenStateManager( + String clientName) { + return new Function<>() { + @Override + public TokenStateManager apply(SyntheticCreationalContext ctx) { + final ReactiveRedisDataSource dataSource; + if (clientName == null) { + dataSource = ctx.getInjectedReference(ReactiveRedisDataSource.class); + } else { + dataSource = ctx.getInjectedReference(ReactiveRedisDataSource.class, + RedisClientName.Literal.of(clientName)); + } + return new OidcRedisTokenStateManager(dataSource); + } + }; + } + +} diff --git a/extensions/oidc-redis-token-state-manager/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/oidc-redis-token-state-manager/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 0000000000000..8178dcf30ace2 --- /dev/null +++ b/extensions/oidc-redis-token-state-manager/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,17 @@ +--- +artifact: ${project.groupId}:${project.artifactId}:${project.version} +name: "OpenID Connect Redis Token State Manager" +metadata: + keywords: + - "oauth2" + - "openid-connect" + - "oidc" + - "oidc-token" + - "oidc-redis-token-state-manager" + - "redis" + guide: "https://quarkus.io/guides/security-openid-connect-client" + categories: + - "security" + status: "preview" + config: + - "quarkus.oidc.redis-token-state-manager." diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java index 97e002f4b65f1..65278ca789fa1 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -1091,7 +1091,8 @@ public Void apply(String cookieValue) { + " with 'quarkus.oidc.token-state-manager.encryption-algorithm=dir'." + " 4. Decrease the session cookie's length by disabling its encryption with 'quarkus.oidc.token-state-manager.encryption-required=false'" + " but only if it is considered to be safe in your application's network." - + " 5. Use the 'quarkus-oidc-db-token-state-manager' extension or register a custom 'quarkus.oidc.TokenStateManager'" + + " 5. Use the 'quarkus-oidc-db-token-state-manager' extension or the 'quarkus-oidc-redis-token-state-manager' extension" + + " or register a custom 'quarkus.oidc.TokenStateManager'" + " CDI bean with the alternative priority set to 1 and save the tokens on the server.", configContext.oidcConfig().tenantId.get(), OidcUtils.MAX_COOKIE_VALUE_LENGTH); diff --git a/extensions/pom.xml b/extensions/pom.xml index c11bbaa9679a1..4e511fcc40963 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -147,6 +147,7 @@ oidc-token-propagation oidc-token-propagation-reactive oidc-db-token-state-manager + oidc-redis-token-state-manager keycloak-authorization keycloak-admin-client-common keycloak-admin-rest-client