diff --git a/bom/application/pom.xml b/bom/application/pom.xml index f2e0e016d66142..d6f265b751b4ec 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -878,6 +878,16 @@ quarkus-oidc-token-propagation-deployment ${project.version} + + io.quarkus + quarkus-oidc-db-token-state-manager + ${project.version} + + + io.quarkus + quarkus-oidc-db-token-state-manager-deployment + ${project.version} + io.quarkus quarkus-oidc-token-propagation-reactive diff --git a/core/deployment/src/main/java/io/quarkus/deployment/Capability.java b/core/deployment/src/main/java/io/quarkus/deployment/Capability.java index 4d0fd6495051ce..5f3d5d46246d1f 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/Capability.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/Capability.java @@ -150,4 +150,9 @@ public interface Capability { String CACHE = QUARKUS_PREFIX + ".cache"; String JDBC_ORACLE = QUARKUS_PREFIX + ".jdbc.oracle"; + String REACTIVE_PG_CLIENT = QUARKUS_PREFIX + ".reactive-pg-client"; + String REACTIVE_ORACLE_CLIENT = QUARKUS_PREFIX + ".reactive-oracle-client"; + String REACTIVE_MYSQL_CLIENT = QUARKUS_PREFIX + ".reactive-mysql-client"; + String REACTIVE_MSSQL_CLIENT = QUARKUS_PREFIX + ".reactive-mssql-client"; + String REACTIVE_DB2_CLIENT = QUARKUS_PREFIX + ".reactive-db2-client"; } diff --git a/devtools/bom-descriptor-json/pom.xml b/devtools/bom-descriptor-json/pom.xml index 230bd9f29e5e79..22c2cca510f18d 100644 --- a/devtools/bom-descriptor-json/pom.xml +++ b/devtools/bom-descriptor-json/pom.xml @@ -1591,6 +1591,19 @@ + + io.quarkus + quarkus-oidc-db-token-state-manager + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-oidc-token-propagation diff --git a/docs/pom.xml b/docs/pom.xml index 5d8fd796d6c594..d28aa46df1760d 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -1607,6 +1607,19 @@ + + io.quarkus + quarkus-oidc-db-token-state-manager-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkus quarkus-oidc-token-propagation-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 1f4eff83f391e2..f657e44d9f0c14 100644 --- a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc @@ -545,7 +545,8 @@ In such cases, use the `quarkus.oidc.token-state-manager.strategy` property to c If your chosen cookie strategy combines tokens and generates a large session cookie value that is greater than 4KB, some browsers might not be able to handle such cookie sizes. This can occur when the ID, access, and refresh tokens are JWT tokens and the selected strategy is `keep-all-tokens` or with ID and refresh tokens when the strategy is `id-refresh-token`. -To workaround this issue, you can set `quarkus.oidc.token-state-manager.split-tokens=true` to create a unique session token for each token. +To workaround this issue, you can set `quarkus.oidc.token-state-manager.split-tokens=true` to create a unique session token for each token. +Another way to workaround this issue is to store tokens in a database. For more information, please see the <> section of this guide. `TokenStateManager` encrypts the tokens before storing them in the session cookie. The following example shows how you configure `TokenStateManager` to split the tokens and encrypt them: @@ -574,7 +575,7 @@ Otherwise, a random key is generated, which can be problematic if the Quarkus ap You can disable token encryption in the session cookie by setting `quarkus.oidc.token-state-manager.encryption-required=false`. Register your own `io.quarkus.oidc.TokenStateManager' implementation as an `@ApplicationScoped` CDI bean if you need to customize the way the tokens are associated with the session cookie. -For example, you may want to keep the tokens in a database and have only a database pointer stored in a session cookie. +For example, you may want to keep the tokens in a cache cluster and have only a key stored in a session cookie. Note that this approach might introduce some challenges if you need to make the tokens available across multiple microservices nodes. Here is a simple example: @@ -633,6 +634,95 @@ public class CustomTokenStateManager implements TokenStateManager { ---- //SJ: In next iteration, propose to recompose Logout information into a new concept topic +[[db-token-state-manager]] +===== Stateful token management + +For simple applications, it can be useful to store tokens in a database, avoiding having to encrypt them in cookies. +To use this feature, add the following extension to your project: + +:add-extension-extensions: oidc-db-token-state-manager +include::{includes}/devtools/extension-add.adoc[] + +This extension will replace the default cookie-based `io.quarkus.oidc.TokenStateManager' with a database-based one. +For authentication is likely to happen on IO thread, the OIDC Database Token State Manager is using a Reactive SQL client under the hood. +Depending on your database, please include and configure exactly one xref:reactive-sql-clients.adoc[Reactive SQL client]. +Following Reactive SQL clients are supported: + +* Reactive MS SQL client +* Reactive MySQL client +* Reactive PostgreSQL client +* Reactive Oracle client +* Reactive DB2 client + +IMPORTANT: By no means your application code needs to use the Reactive SQL client. You can keep using the Hibernate ORM extension with your JDBC driver extension, while the extension will rely on the reactive one. + +Let's say you already have application that is using the Hibernate ORM extension together with a PostgreSQL JDBC Driver and your datasource is configured like this: + +[source, properties] +---- +quarkus.datasource.db-kind=postgresql +quarkus.datasource.username=quarkus_test +quarkus.datasource.password=quarkus_test +quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/quarkus_test +---- + +Now, if you decided to use OIDC Database Token State Manager, you need to add following dependencies and set a reactive driver URL. + +[source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] +.pom.xml +---- + + io.quarkus + quarkus-oidc-db-token-state-manager + + + io.quarkus + quarkus-reactive-pg-client + +---- + +[source, properties] +---- +quarkus.datasource.reactive.url=postgresql://localhost:5432/quarkus_test +---- + +And you are ready to go. + +By default, a database table used for storing tokens is created for you, however you can disable this option with the `quarkus.oidc-db-token-state-manager.create-database-table-if-not-exists` configuration property. +Should you want the Hibernate ORM extension to create this table instead, you simply need to include an Entity like this one: + +[source, java] +---- +package org.acme.manager; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Table(name = "oidc_db_token_state_manager") <1> +@Entity +public class OidcDbTokenStateManagerEntity { + + @Id + String id; + + @Column(name = "id_token", length = 4000) <2> + String idToken; + + @Column(name = "refresh_token", length = 4000) + String refreshToken; + + @Column(name = "access_token", length = 4000) + String accessToken; + + @Column(name = "expires_in") + Long expiresIn; +} +---- +<1> The Hibernate ORM extension will only create this table for you when database schema is generated. Please refer to the xref:hibernate-orm.adoc[Hibernate ORM] guide for more information. +<2> You can choose column length depending on the length of your tokens. + ==== 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-db-token-state-manager/deployment/pom.xml b/extensions/oidc-db-token-state-manager/deployment/pom.xml new file mode 100644 index 00000000000000..f05a8b5b250fca --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/pom.xml @@ -0,0 +1,99 @@ + + + + quarkus-oidc-db-token-state-manager-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-oidc-db-token-state-manager-deployment + Quarkus - OpenID Connect Database Token State Manager - Deployment + + + + io.quarkus + quarkus-oidc-db-token-state-manager + + + io.quarkus + quarkus-oidc-deployment + + + + io.quarkus + quarkus-resteasy-reactive-deployment + test + + + io.quarkus + quarkus-hibernate-orm-deployment + test + + + io.quarkus + quarkus-junit5-internal + test + + + io.rest-assured + rest-assured + test + + + net.sourceforge.htmlunit + htmlunit + test + + + + + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + maven-surefire-plugin + + true + + + + + + + test-keycloak + + + test-containers + + + + + + maven-surefire-plugin + + false + + ${mssql.image} + ${db2.image} + + + + + + + + diff --git a/extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerBuildTimeConfig.java b/extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerBuildTimeConfig.java new file mode 100644 index 00000000000000..a6df4aeb4cecca --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerBuildTimeConfig.java @@ -0,0 +1,17 @@ +package io.quarkus.oidc.db.token.state.manager; + +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "quarkus.oidc-db-token-state-manager") +@ConfigRoot +public interface OidcDbTokenStateManagerBuildTimeConfig { + + /** + * Whether token state should be stored in the database. + */ + @WithDefault("true") + boolean enabled(); + +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerProcessor.java b/extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerProcessor.java new file mode 100644 index 00000000000000..82af608de6dcba --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/main/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerProcessor.java @@ -0,0 +1,200 @@ +package io.quarkus.oidc.db.token.state.manager; + +import static io.quarkus.deployment.Capability.REACTIVE_DB2_CLIENT; +import static io.quarkus.deployment.Capability.REACTIVE_MSSQL_CLIENT; +import static io.quarkus.deployment.Capability.REACTIVE_MYSQL_CLIENT; +import static io.quarkus.deployment.Capability.REACTIVE_ORACLE_CLIENT; +import static io.quarkus.deployment.Capability.REACTIVE_PG_CLIENT; +import static io.quarkus.deployment.annotations.ExecutionTime.RUNTIME_INIT; +import static io.quarkus.deployment.annotations.ExecutionTime.STATIC_INIT; +import static java.lang.String.format; + +import java.util.function.BooleanSupplier; + +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Singleton; + +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.BeanContainerBuildItem; +import io.quarkus.arc.deployment.SyntheticBeanBuildItem; +import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem; +import io.quarkus.arc.deployment.ValidationPhaseBuildItem; +import io.quarkus.builder.item.SimpleBuildItem; +import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.annotations.Consume; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.oidc.TokenStateManager; +import io.quarkus.oidc.db.token.state.manager.runtime.OidcDbTokenStateManager; +import io.quarkus.oidc.db.token.state.manager.runtime.OidcDbTokenStateManagerInitializer; +import io.quarkus.oidc.db.token.state.manager.runtime.OidcDbTokenStateManagerInitializer.OidcDbTokenStateManagerInitializerProperties; +import io.quarkus.oidc.db.token.state.manager.runtime.OidcDbTokenStateManagerRecorder; +import io.quarkus.runtime.configuration.ConfigurationException; + +@BuildSteps(onlyIf = OidcDbTokenStateManagerProcessor.OidcDbTokenStateManagerEnabled.class) +public class OidcDbTokenStateManagerProcessor { + + private static final String[] SUPPORTED_REACTIVE_CLIENTS = new String[] { REACTIVE_PG_CLIENT, REACTIVE_MYSQL_CLIENT, + REACTIVE_MSSQL_CLIENT, REACTIVE_DB2_CLIENT, REACTIVE_ORACLE_CLIENT }; + + @Record(STATIC_INIT) + @BuildStep + SyntheticBeanBuildItem produceDbTokenStateManagerBean(OidcDbTokenStateManagerRecorder recorder, + ReactiveSqlClientBuildItem sqlClientBuildItem) { + final String[] queryParamPlaceholders; + switch (sqlClientBuildItem.reactiveClient) { + case REACTIVE_PG_CLIENT: + queryParamPlaceholders = new String[] { "$1", "$2", "$3", "$4", "$5" }; + break; + case REACTIVE_MSSQL_CLIENT: + queryParamPlaceholders = new String[] { "@p1", "@p2", "@p3", "@p4", "@p5" }; + break; + case REACTIVE_MYSQL_CLIENT: + case REACTIVE_DB2_CLIENT: + case REACTIVE_ORACLE_CLIENT: + queryParamPlaceholders = new String[] { "?", "?", "?", "?", "?" }; + break; + default: + throw new RuntimeException("Unknown Reactive Sql Client " + sqlClientBuildItem.reactiveClient); + } + String deleteStatement = format("DELETE FROM oidc_db_token_state_manager WHERE id = %s", queryParamPlaceholders[0]); + String getQuery = format("SELECT id_token, access_token, refresh_token FROM oidc_db_token_state_manager WHERE " + + "id = %s", queryParamPlaceholders[0]); + String insertStatement = format("INSERT INTO oidc_db_token_state_manager (id_token, access_token, refresh_token," + + " expires_in, id) VALUES (%s, %s, %s, %s, %s)", queryParamPlaceholders[0], queryParamPlaceholders[1], + queryParamPlaceholders[2], queryParamPlaceholders[3], queryParamPlaceholders[4]); + return SyntheticBeanBuildItem + .configure(OidcDbTokenStateManager.class) + .alternative(true) + .priority(1) + .addType(TokenStateManager.class) + .unremovable() + .scope(Singleton.class) + .supplier(recorder.createTokenStateManager(insertStatement, deleteStatement, getQuery)) + .done(); + } + + @BuildStep + ReactiveSqlClientBuildItem validateReactiveSqlClient( + BuildProducer validationErrors, + Capabilities capabilities) { + ReactiveSqlClientBuildItem sqlClientDbTable = null; + for (String reactiveClient : SUPPORTED_REACTIVE_CLIENTS) { + if (capabilities.isPresent(reactiveClient)) { + if (sqlClientDbTable == null) { + sqlClientDbTable = new ReactiveSqlClientBuildItem(reactiveClient); + } else { + validationErrors.produce(new ValidationPhaseBuildItem.ValidationErrorBuildItem( + new ConfigurationException("The OpenID Connect Database Token State Manager extension is " + + "only supported when exactly one Reactive SQL Client extension is present."))); + return null; + } + } + } + if (sqlClientDbTable == null) { + validationErrors.produce(new ValidationPhaseBuildItem.ValidationErrorBuildItem(new ConfigurationException( + "The OpenID Connect Database Token State Manager extension requires Reactive SQL Client extension. " + + "Please refer to the https://quarkus.io/guides/reactive-sql-clients for more information."))); + } + return sqlClientDbTable; + } + + @BuildStep + AdditionalBeanBuildItem makeDbTokenStateManagerInitializerBean() { + return new AdditionalBeanBuildItem(OidcDbTokenStateManagerInitializer.class); + } + + @BuildStep + @Record(STATIC_INIT) + SyntheticBeanBuildItem createDbTokenStateInitializerProps(ReactiveSqlClientBuildItem sqlClientBuildItem, + OidcDbTokenStateManagerRecorder recorder) { + final String createTableDdl; + final boolean supportsIfTableNotExists; + switch (sqlClientBuildItem.reactiveClient) { + case REACTIVE_PG_CLIENT: + createTableDdl = "CREATE TABLE IF NOT EXISTS oidc_db_token_state_manager (" + + "id VARCHAR(100) PRIMARY KEY, " + + "id_token VARCHAR, " + + "access_token VARCHAR, " + + "refresh_token VARCHAR, " + + "expires_in BIGINT NOT NULL)"; + supportsIfTableNotExists = true; + break; + case REACTIVE_MYSQL_CLIENT: + createTableDdl = "CREATE TABLE IF NOT EXISTS oidc_db_token_state_manager (" + + "id VARCHAR(100), " + + "id_token VARCHAR(5000) NULL, " + + "access_token VARCHAR(5000) NULL, " + + "refresh_token VARCHAR(5000) NULL, " + + "expires_in BIGINT NOT NULL, " + + "PRIMARY KEY (id))"; + supportsIfTableNotExists = true; + break; + case REACTIVE_MSSQL_CLIENT: + createTableDdl = "CREATE TABLE oidc_db_token_state_manager (" + + "id NVARCHAR(100) PRIMARY KEY, " + + "id_token NVARCHAR(MAX), " + + "access_token NVARCHAR(MAX), " + + "refresh_token NVARCHAR(MAX), " + + "expires_in BIGINT NOT NULL)"; + supportsIfTableNotExists = false; + break; + case REACTIVE_DB2_CLIENT: + createTableDdl = "CREATE TABLE oidc_db_token_state_manager (" + + "id VARCHAR(100) NOT NULL PRIMARY KEY, " + + "id_token VARCHAR(4000), " + + "access_token VARCHAR(4000), " + + "refresh_token VARCHAR(4000), " + + "expires_in BIGINT NOT NULL)"; + supportsIfTableNotExists = false; + break; + case REACTIVE_ORACLE_CLIENT: + createTableDdl = "CREATE TABLE IF NOT EXISTS oidc_db_token_state_manager (" + + "id VARCHAR2(100), " + + "id_token VARCHAR2(4000), " + + "access_token VARCHAR2(4000), " + + "refresh_token VARCHAR2(4000), " + + "expires_in NUMBER NOT NULL, " + + "PRIMARY KEY (id))"; + supportsIfTableNotExists = true; + break; + default: + throw new ConfigurationException("Unknown Reactive Sql Client " + sqlClientBuildItem.reactiveClient); + } + return SyntheticBeanBuildItem + .configure(OidcDbTokenStateManagerInitializerProperties.class) + .supplier(recorder.createDbTokenStateInitializerProps(createTableDdl, supportsIfTableNotExists)) + .unremovable() + .scope(Dependent.class) + .done(); + } + + @Consume(SyntheticBeansRuntimeInitBuildItem.class) + @Record(RUNTIME_INIT) + @BuildStep + void setSqlClientPool(OidcDbTokenStateManagerRecorder recorder, BeanContainerBuildItem beanContainer) { + recorder.setSqlClientPool(beanContainer.getValue()); + } + + static final class OidcDbTokenStateManagerEnabled implements BooleanSupplier { + + OidcDbTokenStateManagerBuildTimeConfig config; + + @Override + public boolean getAsBoolean() { + return config.enabled(); + } + } + + static final class ReactiveSqlClientBuildItem extends SimpleBuildItem { + + private final String reactiveClient; + + private ReactiveSqlClientBuildItem(String reactiveClient) { + this.reactiveClient = reactiveClient; + } + } + +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/AbstractDbTokenStateManagerTest.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/AbstractDbTokenStateManagerTest.java new file mode 100644 index 00000000000000..e506b2f63d4fcb --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/AbstractDbTokenStateManagerTest.java @@ -0,0 +1,105 @@ +package io.quarkus.oidc.db.token.state.manager; + +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 java.util.List; +import java.util.function.Consumer; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; + +import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; +import com.gargoylesoftware.htmlunit.TextPage; +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.WebRequest; +import com.gargoylesoftware.htmlunit.WebResponse; +import com.gargoylesoftware.htmlunit.html.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlPage; + +import io.quarkus.builder.Version; +import io.quarkus.maven.dependency.Dependency; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.http.TestHTTPResource; +import io.restassured.RestAssured; + +public abstract class AbstractDbTokenStateManagerTest { + + protected static QuarkusUnitTest createQuarkusUnitTest(String reactiveSqlClientExtension) { + return createQuarkusUnitTest(reactiveSqlClientExtension, null); + } + + protected static QuarkusUnitTest createQuarkusUnitTest(String reactiveSqlClientExtension, + Consumer customizer) { + return new QuarkusUnitTest() + .withApplicationRoot((jar) -> { + jar + .addClasses(ProtectedResource.class, UnprotectedResource.class, PublicResource.class) + .addAsResource("application.properties"); + if (customizer != null) { + customizer.accept(jar); + } + }) + .setForcedDependencies( + List.of(Dependency.of("io.quarkus", reactiveSqlClientExtension, Version.getVersion()))); + } + + @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/db-state-manager-table-content") + .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-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/Db2DbTokenStateManagerTest.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/Db2DbTokenStateManagerTest.java new file mode 100644 index 00000000000000..84e87744cdf6c9 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/Db2DbTokenStateManagerTest.java @@ -0,0 +1,20 @@ +package io.quarkus.oidc.db.token.state.manager; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +// TODO: this test works and we simply need to run it, however in CI it is going to hit +// hang detection timeout set by 'quarkus.test.hang-detection-timeout=60', we need to discuss and try +// to find a way to run it (like allow QuarkusUnitTests to override system property etc.) +// but it will require separate PR and make changes unrelated to DB Token State Manager +@EnabledIfSystemProperty(named = "run-db2-db-token-state-manager-test", disabledReason = "Db2 is slow to start", matches = "true") +public class Db2DbTokenStateManagerTest extends AbstractDbTokenStateManagerTest { + + @RegisterExtension + static final QuarkusUnitTest test = createQuarkusUnitTest("quarkus-reactive-db2-client", + jar -> jar.addAsResource(new StringAsset(System.getProperty("db2.image")), "container-license-acceptance.txt")); + +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/GreetingEntity.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/GreetingEntity.java new file mode 100644 index 00000000000000..1ad66681f11acf --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/GreetingEntity.java @@ -0,0 +1,18 @@ +package io.quarkus.oidc.db.token.state.manager; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Table(name = "greeting") +@Entity +public class GreetingEntity { + + @Id + @GeneratedValue + Long id; + + String greeting; + +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/GreetingResource.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/GreetingResource.java new file mode 100644 index 00000000000000..5d9feec729ac5c --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/GreetingResource.java @@ -0,0 +1,36 @@ +package io.quarkus.oidc.db.token.state.manager; + +import java.util.Objects; + +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.quarkus.security.Authenticated; + +@Path("/greeting") +@Authenticated +public class GreetingResource { + + @Inject + EntityManager em; + + @Transactional + @Path("/new") + @GET + public void newGreeting() { + var entity = new GreetingEntity(); + entity.greeting = Objects.requireNonNull("Good day"); + em.persist(entity); + } + + @GET + public Object getGreetings() { + return em + .createNativeQuery("SELECT greeting FROM Greeting") + .getResultList() + .get(0); + } +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/HibernateOrmPgDbTokenStateManagerTest.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/HibernateOrmPgDbTokenStateManagerTest.java new file mode 100644 index 00000000000000..eaa9390090ae4a --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/HibernateOrmPgDbTokenStateManagerTest.java @@ -0,0 +1,92 @@ +package io.quarkus.oidc.db.token.state.manager; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.List; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.WebRequest; +import com.gargoylesoftware.htmlunit.WebResponse; +import com.gargoylesoftware.htmlunit.html.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlPage; + +import io.quarkus.builder.Version; +import io.quarkus.maven.dependency.Dependency; +import io.quarkus.test.QuarkusUnitTest; +import io.restassured.RestAssured; + +public class HibernateOrmPgDbTokenStateManagerTest extends AbstractDbTokenStateManagerTest { + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(ProtectedResource.class, UnprotectedResource.class, PublicResource.class, + GreetingResource.class, GreetingEntity.class, OidcDbTokenStateManagerEntity.class, + OidcDbTokenStateManagerResource.class) + .addAsResource("hibernate-orm-application.properties", "application.properties")) + .setForcedDependencies( + List.of(Dependency.of("io.quarkus", "quarkus-reactive-pg-client", Version.getVersion()), + Dependency.of("io.quarkus", "quarkus-jdbc-postgresql", Version.getVersion()))); + + @Test + public void testCodeFlowOnTableNotCreatedByExtension() throws IOException { + // also tests that this extension works with Hibernate ORM (creates / updates entity) + try (final WebClient webClient = createWebClient()) { + HtmlPage page; + page = webClient.getPage(url.toString() + "greeting/new"); + + assertEquals("Sign in to quarkus", page.getTitleText()); + + HtmlForm loginForm = page.getForms().get(0); + + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + + page = loginForm.getInputByName("login").click(); + assertEquals(200, page.getWebResponse().getStatusCode()); + + WebResponse webResponse = webClient.loadWebResponse( + new WebRequest(URI.create(url.toString() + "greeting").toURL())); + assertEquals(200, webResponse.getStatusCode()); + assertTrue(webResponse.getContentAsString().contains("Good day")); + + webClient.getOptions().setRedirectEnabled(false); + 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(); + } + } + + @Test + public void testExpiredTokenDeletion() { + assertTokenStateCount(0); + + // create 3 tokens + RestAssured + .given() + .body(3) + .post("/token-state-manager-generator") + .then() + .statusCode(204); + assertTokenStateCount(3); + + // make sure expired tokens are deleted + Awaitility + .await() + .ignoreExceptions() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertTokenStateCount(0)); + } +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/MsSqlDbTokenStateManagerTest.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/MsSqlDbTokenStateManagerTest.java new file mode 100644 index 00000000000000..846e020bf35b1e --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/MsSqlDbTokenStateManagerTest.java @@ -0,0 +1,14 @@ +package io.quarkus.oidc.db.token.state.manager; + +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class MsSqlDbTokenStateManagerTest extends AbstractDbTokenStateManagerTest { + + @RegisterExtension + static final QuarkusUnitTest test = createQuarkusUnitTest("quarkus-reactive-mssql-client", + jar -> jar.addAsResource(new StringAsset(System.getProperty("mssql.image")), "container-license-acceptance.txt")); + +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/MySqlDbTokenStateManagerTest.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/MySqlDbTokenStateManagerTest.java new file mode 100644 index 00000000000000..3a729ac393a785 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/MySqlDbTokenStateManagerTest.java @@ -0,0 +1,12 @@ +package io.quarkus.oidc.db.token.state.manager; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class MySqlDbTokenStateManagerTest extends AbstractDbTokenStateManagerTest { + + @RegisterExtension + static final QuarkusUnitTest test = createQuarkusUnitTest("quarkus-reactive-mysql-client"); + +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerEntity.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerEntity.java new file mode 100644 index 00000000000000..ce09a849c551a5 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerEntity.java @@ -0,0 +1,26 @@ +package io.quarkus.oidc.db.token.state.manager; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Table(name = "oidc_db_token_state_manager") +@Entity +public class OidcDbTokenStateManagerEntity { + + @Id + String id; + + @Column(name = "id_token", length = 4000) + String idToken; + + @Column(name = "refresh_token", length = 4000) + String refreshToken; + + @Column(name = "access_token", length = 4000) + String accessToken; + + @Column(name = "expires_in") + Long expiresIn; +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerResource.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerResource.java new file mode 100644 index 00000000000000..c0256f2dc8909a --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/OidcDbTokenStateManagerResource.java @@ -0,0 +1,35 @@ +package io.quarkus.oidc.db.token.state.manager; + +import java.time.Instant; +import java.util.UUID; + +import jakarta.inject.Inject; +import jakarta.persistence.EntityManager; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; + +@Path("/token-state-manager-generator") +public class OidcDbTokenStateManagerResource { + + private static final long EXPIRED_EXTRA_GRACE = 30; + + @Inject + EntityManager em; + + @Transactional + @POST + public void create(Long numOfTokens) { + long expiresIn5Sec = Instant.now().getEpochSecond() + 5 - EXPIRED_EXTRA_GRACE; + for (int i = 0; i < numOfTokens; i++) { + var token = new OidcDbTokenStateManagerEntity(); + token.idToken = "ID TOKEN " + i; + token.accessToken = "ACCESS TOKEN " + i; + token.refreshToken = "REFRESH TOKEN " + i; + token.expiresIn = expiresIn5Sec; + token.id = UUID.randomUUID().toString() + Instant.now().getEpochSecond(); + em.persist(token); + } + } + +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/OracleDbTokenStateManagerTest.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/OracleDbTokenStateManagerTest.java new file mode 100644 index 00000000000000..fa566c7ab3afe1 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/OracleDbTokenStateManagerTest.java @@ -0,0 +1,18 @@ +package io.quarkus.oidc.db.token.state.manager; + +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +// TODO: this test works and we simply need to run it, however in CI it is going to hit +// hang detection timeout set by 'quarkus.test.hang-detection-timeout=60', we need to discuss and try +// to find a way to run it (like allow QuarkusUnitTests to override system property etc.) +// but it will require separate PR and make changes unrelated to DB Token State Manager +@EnabledIfSystemProperty(named = "run-db2-db-token-state-manager-test", disabledReason = "Oracle is slow to start", matches = "true") +public class OracleDbTokenStateManagerTest extends AbstractDbTokenStateManagerTest { + + @RegisterExtension + static final QuarkusUnitTest test = createQuarkusUnitTest("quarkus-reactive-oracle-client"); + +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/PostgresDbTokenStateManagerTest.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/PostgresDbTokenStateManagerTest.java new file mode 100644 index 00000000000000..c38ad37596d669 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/PostgresDbTokenStateManagerTest.java @@ -0,0 +1,12 @@ +package io.quarkus.oidc.db.token.state.manager; + +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +public class PostgresDbTokenStateManagerTest extends AbstractDbTokenStateManagerTest { + + @RegisterExtension + static final QuarkusUnitTest test = createQuarkusUnitTest("quarkus-reactive-pg-client"); + +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/ProtectedResource.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/ProtectedResource.java new file mode 100644 index 00000000000000..c47c7384853cec --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/ProtectedResource.java @@ -0,0 +1,31 @@ +package io.quarkus.oidc.db.token.state.manager; + +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-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/PublicResource.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/PublicResource.java new file mode 100644 index 00000000000000..e0d7b1c4633d88 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/PublicResource.java @@ -0,0 +1,44 @@ +package io.quarkus.oidc.db.token.state.manager; + +import java.util.function.Function; + +import javax.annotation.security.PermitAll; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.smallrye.mutiny.Uni; +import io.vertx.sqlclient.Pool; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowSet; + +@Path("/public") +@PermitAll +public class PublicResource { + + @Inject + Pool pool; + + @Path("/db-state-manager-table-content") + @GET + public Uni getDbStateManagerRowsCount() { + return Uni.createFrom().completionStage(pool + .query("SELECT COUNT(*) FROM oidc_db_token_state_manager") + .execute() + .map(new Function, Long>() { + @Override + public Long apply(RowSet rows) { + if (rows != null) { + var iterator = rows.iterator(); + if (iterator.hasNext()) { + return iterator.next().getLong(0); + } + } + return 0L; + } + }) + .toCompletionStage()); + } + +} diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/UnprotectedResource.java b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/UnprotectedResource.java new file mode 100644 index 00000000000000..830c5a56892f54 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/java/io/quarkus/oidc/db/token/state/manager/UnprotectedResource.java @@ -0,0 +1,13 @@ +package io.quarkus.oidc.db.token.state.manager; + +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-db-token-state-manager/deployment/src/test/resources/application.properties b/extensions/oidc-db-token-state-manager/deployment/src/test/resources/application.properties new file mode 100644 index 00000000000000..3d1deb0eee114c --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/resources/application.properties @@ -0,0 +1,7 @@ +quarkus.oidc.client-id=quarkus-web-app +quarkus.oidc.application-type=web-app +quarkus.oidc.logout.path=/protected/logout +quarkus.log.category."com.gargoylesoftware.htmlunit.javascript.host.css.CSSStyleSheet".level=FATAL +quarkus.log.category."com.gargoylesoftware.htmlunit.css".level=FATAL +quarkus.hibernate-orm.enabled=false +quarkus.datasource.jdbc=false diff --git a/extensions/oidc-db-token-state-manager/deployment/src/test/resources/hibernate-orm-application.properties b/extensions/oidc-db-token-state-manager/deployment/src/test/resources/hibernate-orm-application.properties new file mode 100644 index 00000000000000..6a864e9e87cc81 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/deployment/src/test/resources/hibernate-orm-application.properties @@ -0,0 +1,7 @@ +quarkus.oidc.client-id=quarkus-web-app +quarkus.oidc.application-type=web-app +quarkus.oidc.logout.path=/protected/logout +quarkus.log.category."com.gargoylesoftware.htmlunit.javascript.host.css.CSSStyleSheet".level=FATAL +quarkus.log.category."com.gargoylesoftware.htmlunit.css".level=FATAL +quarkus.oidc-db-token-state-manager.delete-expired-delay=3 +quarkus.oidc-db-token-state-manager.create-database-table-if-not-exists=false diff --git a/extensions/oidc-db-token-state-manager/pom.xml b/extensions/oidc-db-token-state-manager/pom.xml new file mode 100644 index 00000000000000..1e7429381d95f3 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/pom.xml @@ -0,0 +1,20 @@ + + + + quarkus-extensions-parent + io.quarkus + 999-SNAPSHOT + ../pom.xml + + 4.0.0 + + quarkus-oidc-db-token-state-manager-parent + Quarkus - OpenID Connect Database Token State Manager - Parent + pom + + deployment + runtime + + diff --git a/extensions/oidc-db-token-state-manager/runtime/pom.xml b/extensions/oidc-db-token-state-manager/runtime/pom.xml new file mode 100644 index 00000000000000..4b42f1dd76bde5 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/runtime/pom.xml @@ -0,0 +1,46 @@ + + + + quarkus-oidc-db-token-state-manager-parent + io.quarkus + 999-SNAPSHOT + ../ + + 4.0.0 + + quarkus-oidc-db-token-state-manager + Quarkus - OpenID Connect Database Token State Manager - Runtime + Store an OpenID Connect token state in a database + + + io.quarkus + quarkus-oidc + + + io.vertx + vertx-sql-client + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${project.version} + + + + + + + diff --git a/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManager.java b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManager.java new file mode 100644 index 00000000000000..f0d68d358d21f3 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManager.java @@ -0,0 +1,150 @@ +package io.quarkus.oidc.db.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 org.jboss.logging.Logger; + +import io.quarkus.oidc.AuthorizationCodeTokens; +import io.quarkus.oidc.OidcRequestContext; +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.TokenStateManager; +import io.quarkus.security.AuthenticationCompletionException; +import io.quarkus.security.AuthenticationFailedException; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; +import io.vertx.sqlclient.Pool; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowIterator; +import io.vertx.sqlclient.RowSet; +import io.vertx.sqlclient.Tuple; + +public class OidcDbTokenStateManager implements TokenStateManager { + + private static final Logger LOG = Logger.getLogger(OidcDbTokenStateManager.class); + private static final String TOKEN_STATE_INSERT_FAILED = "Failed to insert token state into database"; + private static final String FAILED_TO_ACQUIRE_TOKEN = "Failed to acquire authorization code tokens"; + private final String insertStatement; + private final String deleteStatement; + private final String getQuery; + private Pool pool; + + OidcDbTokenStateManager(String insertStatement, String deleteStatement, String getQuery) { + this.insertStatement = insertStatement; + this.deleteStatement = deleteStatement; + this.getQuery = getQuery; + } + + void setSqlClientPool(Pool pool) { + this.pool = pool; + } + + @Override + public Uni createTokenState(RoutingContext event, OidcTenantConfig oidcConfig, + AuthorizationCodeTokens tokens, OidcRequestContext requestContext) { + final String id = now() + UUID.randomUUID().toString(); + return Uni + .createFrom() + .completionStage( + pool + .withTransaction(client -> client + .preparedQuery(insertStatement) + .execute( + Tuple.of(tokens.getIdToken(), tokens.getAccessToken(), tokens.getRefreshToken(), + expiresIn(event, oidcConfig), id))) + .toCompletionStage()) + .onFailure().transform(new Function() { + @Override + public Throwable apply(Throwable throwable) { + return new AuthenticationFailedException(TOKEN_STATE_INSERT_FAILED, throwable); + } + }) + .flatMap(new Function, Uni>() { + @Override + public Uni apply(RowSet rows) { + if (rows != null) { + return Uni.createFrom().item(id); + } + return Uni.createFrom().failure(new AuthenticationFailedException(TOKEN_STATE_INSERT_FAILED)); + } + }) + .memoize().indefinitely(); + } + + @Override + public Uni getTokens(RoutingContext routingContext, OidcTenantConfig oidcConfig, String tokenState, + OidcRequestContext requestContext) { + return Uni + .createFrom() + .completionStage( + pool + .preparedQuery(getQuery) + .execute(Tuple.of(tokenState)) + .toCompletionStage()) + .onFailure().transform(new Function() { + @Override + public Throwable apply(Throwable throwable) { + return new AuthenticationCompletionException(FAILED_TO_ACQUIRE_TOKEN, throwable); + } + }) + .flatMap(new Function, Uni>() { + @Override + public Uni apply(RowSet rows) { + if (rows != null) { + final RowIterator iterator = rows.iterator(); + if (iterator.hasNext()) { + final Row firstRow = iterator.next(); + return Uni + .createFrom() + .item(new AuthorizationCodeTokens( + firstRow.getString("id_token"), + firstRow.getString("access_token"), + firstRow.getString("refresh_token"))); + } + } + return Uni.createFrom().failure(new AuthenticationCompletionException(FAILED_TO_ACQUIRE_TOKEN)); + } + }) + .memoize().indefinitely(); + } + + @Override + public Uni deleteTokens(RoutingContext routingContext, OidcTenantConfig oidcConfig, String tokenState, + OidcRequestContext requestContext) { + return Uni + .createFrom() + .completionStage(pool + .preparedQuery(deleteStatement) + .execute(Tuple.of(tokenState)) + .toCompletionStage()) + .replaceWithVoid() + .onFailure() + .recoverWithItem(new Function() { + @Override + public Void apply(Throwable throwable) { + LOG.debugf("Failed to delete tokens: %s", throwable.getMessage()); + return null; + } + }); + } + + static long now() { + return Instant.now().getEpochSecond(); + } + + private static long expiresIn(RoutingContext event, OidcTenantConfig oidcConfig) { + Long maxAge = event. get(SESSION_MAX_AGE_PARAM); + if (maxAge == null) { + // illegal state that shouldn't happen + throw new AuthenticationFailedException(SESSION_MAX_AGE_PARAM + " parameter is missing"); + } + maxAge += oidcConfig.token.lifespanGrace.orElse(0); + if (oidcConfig.token.refreshExpired) { + maxAge += oidcConfig.authentication.sessionAgeExtension.getSeconds(); + } + return now() + maxAge; + } +} diff --git a/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerInitializer.java b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerInitializer.java new file mode 100644 index 00000000000000..0f4fce54ddbf0f --- /dev/null +++ b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerInitializer.java @@ -0,0 +1,145 @@ +package io.quarkus.oidc.db.token.state.manager.runtime; + +import static io.quarkus.oidc.db.token.state.manager.runtime.OidcDbTokenStateManager.now; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; + +import jakarta.enterprise.event.Observes; + +import org.jboss.logging.Logger; + +import io.quarkus.runtime.StartupEvent; +import io.smallrye.mutiny.Uni; +import io.vertx.core.Handler; +import io.vertx.core.Vertx; +import io.vertx.sqlclient.Pool; +import io.vertx.sqlclient.Row; +import io.vertx.sqlclient.RowSet; + +public class OidcDbTokenStateManagerInitializer { + + private static final Logger LOG = Logger.getLogger(OidcDbTokenStateManagerInitializer.class); + private static final String FAILED_TO_CREATE_DB_TABLE = "unknown reason, please report the issue and create table manually"; + /** + * Extra 30 seconds before we delete expired tokens. + */ + private static final long EXPIRED_EXTRA_GRACE = 30; + + void initialize(@Observes StartupEvent event, OidcDbTokenStateManagerRunTimeConfig config, Vertx vertx, Pool pool, + OidcDbTokenStateManagerInitializerProperties initializerProps) { + if (config.createDatabaseTableIfNotExists()) { + createDatabaseTable(pool, initializerProps.createTableDdl, initializerProps.supportsIfTableNotExists); + } + periodicallyDeleteExpiredTokens(vertx, pool, config.deleteExpiredDelay().toMillis()); + } + + private static void periodicallyDeleteExpiredTokens(Vertx vertx, Pool pool, long delayBetweenChecks) { + vertx + .setPeriodic(5000, delayBetweenChecks, new Handler() { + + private final AtomicBoolean deleteInProgress = new AtomicBoolean(false); + + @Override + public void handle(Long aLong) { + if (deleteInProgress.compareAndSet(false, true)) { + + final long deleteExpiresIn = now() - EXPIRED_EXTRA_GRACE; + Uni.createFrom().completionStage( + pool + .query("DELETE FROM oidc_db_token_state_manager WHERE expires_in < " + + deleteExpiresIn) + .execute() + .toCompletionStage()) + .subscribe() + .with( + new Consumer>() { + @Override + public void accept(RowSet ignored) { + // success + deleteInProgress.set(false); + } + }, + new Consumer() { + @Override + public void accept(Throwable t) { + LOG.errorf("Failed to expired OIDC token states from database: %s", + t.getMessage()); + deleteInProgress.set(false); + } + }); + } + } + }); + } + + private static void createDatabaseTable(Pool pool, String createTableDdl, boolean supportsIfTableNotExists) { + LOG.debugf("Creating database table with query: %s", createTableDdl); + String errMsg = Uni + .createFrom() + .completionStage( + pool + .query(createTableDdl) + .execute() + .toCompletionStage()) + .onItemOrFailure() + .transformToUni(new BiFunction, Throwable, Uni>() { + @Override + public Uni apply(RowSet rows, Throwable throwable) { + if (throwable != null) { + if (supportsIfTableNotExists) { + return Uni.createFrom().item(throwable.getMessage()); + } else { + // most likely we tried to create table even though it already exists + return Uni.createFrom().nullItem(); + } + } + // assert table exists + return Uni + .createFrom() + .completionStage(pool + // use MAX in order to limit response size + // and LIMIT clause is not supported by all the databases + .query("SELECT MAX(id) FROM oidc_db_token_state_manager") + .execute() + .toCompletionStage()) + .map(new Function, String>() { + @Override + public String apply(RowSet rows) { + if (rows != null && rows.columnsNames().size() == 1) { + // table exists + return null; + } + // table does not exist + return FAILED_TO_CREATE_DB_TABLE; + } + }) + .onFailure().recoverWithItem(new Function() { + @Override + public String apply(Throwable throwable) { + LOG.error("Create database query failed with: ", throwable); + return FAILED_TO_CREATE_DB_TABLE; + } + }); + } + }) + .await() + .indefinitely(); + if (errMsg != null) { + throw new RuntimeException("OIDC Token State Manager failed to create database table: " + errMsg); + } + } + + public static final class OidcDbTokenStateManagerInitializerProperties { + + private final String createTableDdl; + private final boolean supportsIfTableNotExists; + + OidcDbTokenStateManagerInitializerProperties(String createTableDdl, boolean supportsIfTableNotExists) { + this.createTableDdl = createTableDdl; + this.supportsIfTableNotExists = supportsIfTableNotExists; + } + } +} diff --git a/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerRecorder.java b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerRecorder.java new file mode 100644 index 00000000000000..3a69c6bf2dbb08 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerRecorder.java @@ -0,0 +1,39 @@ +package io.quarkus.oidc.db.token.state.manager.runtime; + +import java.util.function.Supplier; + +import io.quarkus.arc.runtime.BeanContainer; +import io.quarkus.oidc.db.token.state.manager.runtime.OidcDbTokenStateManagerInitializer.OidcDbTokenStateManagerInitializerProperties; +import io.quarkus.runtime.annotations.Recorder; +import io.vertx.sqlclient.Pool; + +@Recorder +public class OidcDbTokenStateManagerRecorder { + + /* STATIC INIT */ + public Supplier createTokenStateManager(String insertStatement, String deleteStatement, + String getQuery) { + return new Supplier() { + @Override + public OidcDbTokenStateManager get() { + return new OidcDbTokenStateManager(insertStatement, deleteStatement, getQuery); + } + }; + } + + /* RUNTIME INIT */ + public void setSqlClientPool(BeanContainer container) { + container.beanInstance(OidcDbTokenStateManager.class).setSqlClientPool(container.beanInstance(Pool.class)); + } + + /* STATIC INIT */ + public Supplier createDbTokenStateInitializerProps(String createTableDdl, + boolean supportsIfTableNotExists) { + return new Supplier() { + @Override + public OidcDbTokenStateManagerInitializerProperties get() { + return new OidcDbTokenStateManagerInitializerProperties(createTableDdl, supportsIfTableNotExists); + } + }; + } +} diff --git a/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerRunTimeConfig.java b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerRunTimeConfig.java new file mode 100644 index 00000000000000..ef64ee77904284 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/runtime/src/main/java/io/quarkus/oidc/db/token/state/manager/runtime/OidcDbTokenStateManagerRunTimeConfig.java @@ -0,0 +1,26 @@ +package io.quarkus.oidc.db.token.state.manager.runtime; + +import static io.quarkus.runtime.annotations.ConfigPhase.RUN_TIME; + +import java.time.Duration; + +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +@ConfigMapping(prefix = "quarkus.oidc-db-token-state-manager") +@ConfigRoot(phase = RUN_TIME) +public interface OidcDbTokenStateManagerRunTimeConfig { + + /** + * How often should Quarkus check for expired tokens. + */ + @WithDefault("8h") + Duration deleteExpiredDelay(); + + /** + * Whether Quarkus should attempt to create database table where the token state is going to be stored. + */ + @WithDefault("true") + boolean createDatabaseTableIfNotExists(); +} diff --git a/extensions/oidc-db-token-state-manager/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/extensions/oidc-db-token-state-manager/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 00000000000000..fb4805694ece72 --- /dev/null +++ b/extensions/oidc-db-token-state-manager/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,16 @@ +--- +artifact: ${project.groupId}:${project.artifactId}:${project.version} +name: "OpenID Connect Database Token State Manager" +metadata: + keywords: + - "oauth2" + - "openid-connect" + - "oidc" + - "oidc-token" + - "oidc-db-token-state-manager" + guide: "https://quarkus.io/guides/security-openid-connect-client" + categories: + - "security" + status: "preview" + config: + - "quarkus.oidc-db-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 be150ea8a97a0c..1dbf394b2da866 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 @@ -60,13 +60,13 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMechanism { + public static final String SESSION_MAX_AGE_PARAM = "session-max-age"; static final String AMP = "&"; static final String EQ = "="; static final String COMMA = ","; static final String UNDERSCORE = "_"; static final String COOKIE_DELIM = "|"; static final Pattern COOKIE_PATTERN = Pattern.compile("\\" + COOKIE_DELIM); - static final String SESSION_MAX_AGE_PARAM = "session-max-age"; static final String STATE_COOKIE_RESTORE_PATH = "restore-path"; static final Uni VOID_UNI = Uni.createFrom().voidItem(); static final Integer MAX_COOKIE_VALUE_LENGTH = 4096; diff --git a/extensions/pom.xml b/extensions/pom.xml index 81f2fbfa8d0848..4ebe3559695e1f 100644 --- a/extensions/pom.xml +++ b/extensions/pom.xml @@ -141,6 +141,7 @@ oidc-client-reactive-filter oidc-token-propagation oidc-token-propagation-reactive + oidc-db-token-state-manager keycloak-authorization keycloak-admin-client-common keycloak-admin-client diff --git a/extensions/reactive-db2-client/runtime/pom.xml b/extensions/reactive-db2-client/runtime/pom.xml index 8ea8d89f8db39c..254cda26cb106e 100644 --- a/extensions/reactive-db2-client/runtime/pom.xml +++ b/extensions/reactive-db2-client/runtime/pom.xml @@ -49,6 +49,11 @@ io.quarkus quarkus-extension-maven-plugin + + + io.quarkus.reactive-db2-client + + maven-compiler-plugin diff --git a/extensions/reactive-mssql-client/runtime/pom.xml b/extensions/reactive-mssql-client/runtime/pom.xml index 8fa6801bdb7fa0..aa9ba57b9f1449 100644 --- a/extensions/reactive-mssql-client/runtime/pom.xml +++ b/extensions/reactive-mssql-client/runtime/pom.xml @@ -53,6 +53,11 @@ io.quarkus quarkus-extension-maven-plugin + + + io.quarkus.reactive-mssql-client + + maven-compiler-plugin diff --git a/extensions/reactive-mysql-client/runtime/pom.xml b/extensions/reactive-mysql-client/runtime/pom.xml index f8229fb01f6491..f98ea175f5550a 100644 --- a/extensions/reactive-mysql-client/runtime/pom.xml +++ b/extensions/reactive-mysql-client/runtime/pom.xml @@ -69,6 +69,11 @@ io.quarkus quarkus-extension-maven-plugin + + + io.quarkus.reactive-mysql-client + + maven-compiler-plugin diff --git a/extensions/reactive-oracle-client/runtime/pom.xml b/extensions/reactive-oracle-client/runtime/pom.xml index 2a07cfd2169f3d..842a846f3c9b11 100644 --- a/extensions/reactive-oracle-client/runtime/pom.xml +++ b/extensions/reactive-oracle-client/runtime/pom.xml @@ -57,6 +57,11 @@ io.quarkus quarkus-extension-maven-plugin + + + io.quarkus.reactive-oracle-client + + maven-compiler-plugin diff --git a/extensions/reactive-pg-client/runtime/pom.xml b/extensions/reactive-pg-client/runtime/pom.xml index 72d18586745532..21ea768d8973b8 100644 --- a/extensions/reactive-pg-client/runtime/pom.xml +++ b/extensions/reactive-pg-client/runtime/pom.xml @@ -57,6 +57,11 @@ io.quarkus quarkus-extension-maven-plugin + + + io.quarkus.reactive-pg-client + + maven-compiler-plugin