From 2b0b1f2c2d67ee954f310d9a913ef233a456526d Mon Sep 17 00:00:00 2001 From: Alexander Schwartz Date: Wed, 20 Mar 2024 12:56:18 +0100 Subject: [PATCH 01/25] Keep the URIs in the metrics tag if they match a client or server pattern Closes #39581 Co-authored-by: Erin Schnabel --- .../runtime/binder/HttpCommonTags.java | 39 ++++++++++++++----- .../binder/RestClientMetricsFilter.java | 2 +- .../binder/vertx/VertxHttpClientMetrics.java | 2 +- .../binder/vertx/VertxHttpServerMetrics.java | 8 ++-- .../runtime/binder/HttpCommonTagsTest.java | 20 ++++++---- 5 files changed, 47 insertions(+), 24 deletions(-) diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/HttpCommonTags.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/HttpCommonTags.java index 656736a6d345cc..7cab35f4f0446e 100644 --- a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/HttpCommonTags.java +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/HttpCommonTags.java @@ -1,5 +1,7 @@ package io.quarkus.micrometer.runtime.binder; +import java.util.Objects; + import io.micrometer.core.instrument.Tag; import io.micrometer.core.instrument.binder.http.Outcome; @@ -46,22 +48,18 @@ public static Tag outcome(int statusCode) { /** * Creates a {@code uri} tag based on the URI of the given {@code request}. - * Falling back to {@code REDIRECTION} for 3xx responses, {@code NOT_FOUND} - * for 404 responses, {@code root} for requests with no path info, and {@code UNKNOWN} + * Falling back to {@code REDIRECTION} for 3xx responses if there wasn't a matched path pattern, {@code NOT_FOUND} + * for 404 responses if there wasn't a matched path pattern, {@code root} for requests with no path info, and + * {@code UNKNOWN} * for all other requests. * * @param pathInfo request path + * @param initialPath initial path before request pattern matching took place. Pass in null if there is pattern matching + * done in the caller. * @param code status code of the response * @return the uri tag derived from the request */ - public static Tag uri(String pathInfo, int code) { - if (code > 0) { - if (code / 100 == 3) { - return URI_REDIRECTION; - } else if (code == 404) { - return URI_NOT_FOUND; - } - } + public static Tag uri(String pathInfo, String initialPath, int code) { if (pathInfo == null) { return URI_UNKNOWN; } @@ -69,7 +67,28 @@ public static Tag uri(String pathInfo, int code) { return URI_ROOT; } + if (code > 0) { + if (code / 100 == 3) { + if (isTemplatedPath(pathInfo, initialPath)) { + return Tag.of("uri", pathInfo); + } else { + return URI_REDIRECTION; + } + } else if (code == 404) { + if (isTemplatedPath(pathInfo, initialPath)) { + return Tag.of("uri", pathInfo); + } else { + return URI_NOT_FOUND; + } + } + } + // Use first segment of request path return Tag.of("uri", pathInfo); } + + private static boolean isTemplatedPath(String pathInfo, String initialPath) { + // only include the path info if it has been matched to a template (initialPath != pathInfo) to avoid a metrics explosion with lots of entries + return initialPath != null && !Objects.equals(initialPath, pathInfo); + } } diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/RestClientMetricsFilter.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/RestClientMetricsFilter.java index 004aa63e2d1627..f1c6acb745eed1 100644 --- a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/RestClientMetricsFilter.java +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/RestClientMetricsFilter.java @@ -72,7 +72,7 @@ public void filter(final ClientRequestContext requestContext, final ClientRespon Timer.Builder builder = Timer.builder(httpMetricsConfig.getHttpClientRequestsName()) .tags(Tags.of( HttpCommonTags.method(requestContext.getMethod()), - HttpCommonTags.uri(requestPath, statusCode), + HttpCommonTags.uri(requestPath, requestContext.getUri().getPath(), statusCode), HttpCommonTags.outcome(statusCode), HttpCommonTags.status(statusCode), clientName(requestContext))); diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpClientMetrics.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpClientMetrics.java index 8e3fc3474b8e15..5d346eee3428ac 100644 --- a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpClientMetrics.java +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpClientMetrics.java @@ -183,7 +183,7 @@ public static class RequestTracker extends RequestMetricInfo { this.tags = origin.and( Tag.of("address", address), HttpCommonTags.method(method), - HttpCommonTags.uri(path, -1)); + HttpCommonTags.uri(path, null, -1)); } void requestReset() { diff --git a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpServerMetrics.java b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpServerMetrics.java index 6f22060edc2500..23b605d5461093 100644 --- a/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpServerMetrics.java +++ b/extensions/micrometer/runtime/src/main/java/io/quarkus/micrometer/runtime/binder/vertx/VertxHttpServerMetrics.java @@ -99,7 +99,7 @@ public HttpRequestMetric responsePushed(LongTaskTimer.Sample socketMetric, HttpM config.getServerIgnorePatterns()); if (path != null) { registry.counter(nameHttpServerPush, Tags.of( - HttpCommonTags.uri(path, response.statusCode()), + HttpCommonTags.uri(path, requestMetric.initialPath, response.statusCode()), VertxMetricsTags.method(method), VertxMetricsTags.outcome(response), HttpCommonTags.status(response.statusCode()))) @@ -153,7 +153,7 @@ public void requestReset(HttpRequestMetric requestMetric) { Timer.Builder builder = Timer.builder(nameHttpServerRequests) .tags(Tags.of( VertxMetricsTags.method(requestMetric.request().method()), - HttpCommonTags.uri(path, 0), + HttpCommonTags.uri(path, requestMetric.initialPath, 0), Outcome.CLIENT_ERROR.asTag(), HttpCommonTags.STATUS_RESET)); @@ -180,7 +180,7 @@ public void responseEnd(HttpRequestMetric requestMetric, HttpResponse response, Timer.Sample sample = requestMetric.getSample(); Tags allTags = Tags.of( VertxMetricsTags.method(requestMetric.request().method()), - HttpCommonTags.uri(path, response.statusCode()), + HttpCommonTags.uri(path, requestMetric.initialPath, response.statusCode()), VertxMetricsTags.outcome(response), HttpCommonTags.status(response.statusCode())); if (!httpServerMetricsTagsContributors.isEmpty()) { @@ -217,7 +217,7 @@ public LongTaskTimer.Sample connected(LongTaskTimer.Sample sample, HttpRequestMe config.getServerIgnorePatterns()); if (path != null) { return LongTaskTimer.builder(nameWebsocketConnections) - .tags(Tags.of(HttpCommonTags.uri(path, 0))) + .tags(Tags.of(HttpCommonTags.uri(path, requestMetric.initialPath, 0))) .register(registry) .start(); } diff --git a/extensions/micrometer/runtime/src/test/java/io/quarkus/micrometer/runtime/binder/HttpCommonTagsTest.java b/extensions/micrometer/runtime/src/test/java/io/quarkus/micrometer/runtime/binder/HttpCommonTagsTest.java index 2474a9f228c6e9..e6bf10ee83dbc9 100644 --- a/extensions/micrometer/runtime/src/test/java/io/quarkus/micrometer/runtime/binder/HttpCommonTagsTest.java +++ b/extensions/micrometer/runtime/src/test/java/io/quarkus/micrometer/runtime/binder/HttpCommonTagsTest.java @@ -21,17 +21,21 @@ public void testStatus() { @Test public void testUriRedirect() { - Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", 301)); - Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", 302)); - Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", 304)); + Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", null, 301)); + Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", null, 302)); + Assertions.assertEquals(HttpCommonTags.URI_REDIRECTION, HttpCommonTags.uri("/moved", null, 304)); + Assertions.assertEquals(Tag.of("uri", "/moved/{id}"), HttpCommonTags.uri("/moved/{id}", "/moved/111", 304)); + Assertions.assertEquals(HttpCommonTags.URI_ROOT, HttpCommonTags.uri("/", null, 304)); } @Test public void testUriDefaults() { - Assertions.assertEquals(HttpCommonTags.URI_ROOT, HttpCommonTags.uri("/", 200)); - Assertions.assertEquals(Tag.of("uri", "/known/ok"), HttpCommonTags.uri("/known/ok", 200)); - Assertions.assertEquals(HttpCommonTags.URI_NOT_FOUND, HttpCommonTags.uri("/invalid", 404)); - Assertions.assertEquals(Tag.of("uri", "/known/bad/request"), HttpCommonTags.uri("/known/bad/request", 400)); - Assertions.assertEquals(Tag.of("uri", "/known/server/error"), HttpCommonTags.uri("/known/server/error", 500)); + Assertions.assertEquals(HttpCommonTags.URI_ROOT, HttpCommonTags.uri("/", null, 200)); + Assertions.assertEquals(HttpCommonTags.URI_ROOT, HttpCommonTags.uri("/", null, 404)); + Assertions.assertEquals(Tag.of("uri", "/known/ok"), HttpCommonTags.uri("/known/ok", null, 200)); + Assertions.assertEquals(HttpCommonTags.URI_NOT_FOUND, HttpCommonTags.uri("/invalid", null, 404)); + Assertions.assertEquals(Tag.of("uri", "/invalid/{id}"), HttpCommonTags.uri("/invalid/{id}", "/invalid/111", 404)); + Assertions.assertEquals(Tag.of("uri", "/known/bad/request"), HttpCommonTags.uri("/known/bad/request", null, 400)); + Assertions.assertEquals(Tag.of("uri", "/known/server/error"), HttpCommonTags.uri("/known/server/error", null, 500)); } } From c355fa1ae4dff1eb6294101e10d216c676e9abcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Tue, 23 Jan 2024 18:04:31 +0100 Subject: [PATCH 02/25] Improved WebAuthn docs Resolving several issues in discussions --- docs/src/main/asciidoc/security-webauthn.adoc | 32 ++++++++++++++----- .../security/webauthn/WebAuthnController.java | 6 ++-- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/docs/src/main/asciidoc/security-webauthn.adoc b/docs/src/main/asciidoc/security-webauthn.adoc index 7817418252ffdd..a3177c7ed80682 100644 --- a/docs/src/main/asciidoc/security-webauthn.adoc +++ b/docs/src/main/asciidoc/security-webauthn.adoc @@ -19,6 +19,7 @@ include::{includes}/extension-status.adoc[] == Prerequisites include::{includes}/prerequisites.adoc[] +* A WebAuthn or PassKeys-capable device, or https://developer.chrome.com/docs/devtools/webauthn/[an emulator of those]. == Introduction to WebAuthn @@ -62,6 +63,10 @@ login or registration. And also there are a lot more fields to store than just a public key, but we will help you with that. +Just in case you get there wondering what's the relation with https://fidoalliance.org/passkeys/[PassKeys] +and whether we support it: sure, yes, PassKeys is a way that your authenticator devices can share and sync +their credentials, which you can then use with our WebAuthn authentication. + == Architecture In this example, we build a very simple microservice which offers four endpoints: @@ -544,6 +549,7 @@ in `src/main/resources/META-INF/resources/index.html`:
  • User API
  • Admin API
  • Logout
  • +
    @@ -582,7 +588,7 @@ in `src/main/resources/META-INF/resources/index.html`: const loginButton = document.getElementById('login'); - loginButton.onclick = () => { + loginButton.addEventListener("click", (e) => { var userName = document.getElementById('userNameLogin').value; result.replaceChildren(); webAuthn.login({ name: userName }) @@ -593,11 +599,11 @@ in `src/main/resources/META-INF/resources/index.html`: result.append("Login failed: "+err); }); return false; - }; + }); const registerButton = document.getElementById('register'); - registerButton.onclick = () => { + registerButton.addEventListener("click", (e) => { var userName = document.getElementById('userNameRegister').value; var firstName = document.getElementById('firstName').value; var lastName = document.getElementById('lastName').value; @@ -610,7 +616,7 @@ in `src/main/resources/META-INF/resources/index.html`: result.append("Registration failed: "+err); }); return false; - }; + }); @@ -639,7 +645,8 @@ form on the right, then pressing the `Register` button: image::webauthn-2.png[role="thumb"] -Your browser will ask you to activate your WebAuthn authenticator: +Your browser will ask you to activate your WebAuthn authenticator (you will need a WebAuthn-capable browser +and possibly device, or you can use https://developer.chrome.com/docs/devtools/webauthn/[an emulator of those]): image::webauthn-3.png[role="thumb"] @@ -669,11 +676,14 @@ The Quarkus WebAuthn extension comes out of the box with these REST endpoints pr .Request ---- { - "name": "userName", - "displayName": "Mr Nice Guy" + "name": "userName", <1> + "displayName": "Mr Nice Guy" <2> } ---- +<1> Required +<2> Optional + [source,json] .Response ---- @@ -717,10 +727,12 @@ The Quarkus WebAuthn extension comes out of the box with these REST endpoints pr .Request ---- { - "name": "userName" + "name": "userName" <1> } ---- +<1> Required + [source,json] .Response ---- @@ -1258,6 +1270,10 @@ public class TestUserProvider extends MyWebAuthnSetup { [[configuration-reference]] == Configuration Reference +The security encryption key can be set with the +link:all-config#quarkus-vertx-http_quarkus.http.auth.session.encryption-key[`quarkus.http.auth.session.encryption-key`] +configuration option, as described in the link:security-authentication-mechanisms#form-auth[security guide]. + include::{generated-dir}/config/quarkus-security-webauthn.adoc[opts=optional, leveloffset=+1] == References diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java index 23788400a772be..50f8a2ad6fb583 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java @@ -97,7 +97,7 @@ private static boolean containsRequiredObject(JsonObject json, String key) { } /** - * Endpoint for register + * Endpoint for getting a register challenge * * @param ctx the current request */ @@ -143,7 +143,7 @@ public void register(RoutingContext ctx) { } /** - * Endpoint for login + * Endpoint for getting a login challenge * * @param ctx the current request */ @@ -189,7 +189,7 @@ public void login(RoutingContext ctx) { } /** - * Endpoint for callback + * Endpoint for getting authenticated * * @param ctx the current request */ From f201e27645ec76509c5b068d341d15cd111c1ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Wed, 24 Jan 2024 15:51:34 +0100 Subject: [PATCH 03/25] WebAuthn: allow cookie customisation Fixes #38351 --- docs/src/main/asciidoc/security-webauthn.adoc | 6 +- .../test/WebAuthnManualCustomCookiesTest.java | 131 ++++++++++++++++++ .../webauthn/test/WebAuthnManualTest.java | 9 +- .../security/webauthn/WebAuthnController.java | 24 ++-- .../webauthn/WebAuthnRunTimeConfig.java | 12 ++ .../security/webauthn/WebAuthnSecurity.java | 20 +-- .../webauthn/test/WebAuthnResourceTest.java | 7 +- .../webauthn/WebAuthnEndpointHelper.java | 36 +++-- 8 files changed, 205 insertions(+), 40 deletions(-) create mode 100644 extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java diff --git a/docs/src/main/asciidoc/security-webauthn.adoc b/docs/src/main/asciidoc/security-webauthn.adoc index a3177c7ed80682..67ac942f5bc939 100644 --- a/docs/src/main/asciidoc/security-webauthn.adoc +++ b/docs/src/main/asciidoc/security-webauthn.adoc @@ -1151,9 +1151,9 @@ public class WebAuthnResourceTest { .then() .statusCode(200) .log().ifValidationFails() - .cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.is("")) - .cookie(WebAuthnController.USERNAME_COOKIE, Matchers.is("")) - .cookie("quarkus-credential", Matchers.notNullValue()); + .cookie(WebAuthnEndpointHelper.getChallengeCookie(), Matchers.is("")) + .cookie(WebAuthnEndpointHelper.getChallengeUsernameCookie(), Matchers.is("")) + .cookie(WebAuthnEndpointHelper.getMainCookie(), Matchers.notNullValue()); } private void verifyLoggedIn(Filter cookieFilter, String userName, User user) { diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java new file mode 100644 index 00000000000000..47489fae56e8d3 --- /dev/null +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualCustomCookiesTest.java @@ -0,0 +1,131 @@ +package io.quarkus.security.webauthn.test; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.hamcrest.Matchers; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; +import io.quarkus.test.security.webauthn.WebAuthnHardware; +import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; +import io.restassured.RestAssured; +import io.restassured.filter.cookie.CookieFilter; +import io.restassured.specification.RequestSpecification; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.webauthn.Authenticator; + +/** + * Same test as WebAuthnManualTest but with custom cookies configured + */ +public class WebAuthnManualCustomCookiesTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .add(new StringAsset("quarkus.webauthn.cookie-name=main-cookie\n" + + "quarkus.webauthn.challenge-cookie-name=challenge-cookie\n" + + "quarkus.webauthn.challenge-username-cookie-name=username-cookie\n"), "application.properties") + .addClasses(WebAuthnManualTestUserProvider.class, WebAuthnTestUserProvider.class, WebAuthnHardware.class, + TestResource.class, ManualResource.class, TestUtil.class)); + + @Inject + WebAuthnUserProvider userProvider; + + @Test + public void test() throws Exception { + + RestAssured.get("/open").then().statusCode(200).body(Matchers.is("Hello")); + RestAssured + .given().redirects().follow(false) + .get("/secure").then().statusCode(302); + RestAssured + .given().redirects().follow(false) + .get("/admin").then().statusCode(302); + RestAssured + .given().redirects().follow(false) + .get("/cheese").then().statusCode(302); + + Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty()); + CookieFilter cookieFilter = new CookieFilter(); + String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); + WebAuthnHardware hardwareKey = new WebAuthnHardware(); + JsonObject registration = hardwareKey.makeRegistrationJson(challenge); + + // now finalise + RequestSpecification request = RestAssured + .given() + .filter(cookieFilter); + WebAuthnEndpointHelper.addWebAuthnRegistrationFormParameters(request, registration); + request + .post("/register") + .then().statusCode(200) + .body(Matchers.is("OK")) + .cookie("challenge-cookie", Matchers.is("")) + .cookie("username-cookie", Matchers.is("")) + .cookie("main-cookie", Matchers.notNullValue()); + + // make sure we stored the user + List users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + Assertions.assertEquals(1, users.size()); + Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertEquals(1, users.get(0).getCounter()); + + // make sure our login cookie works + checkLoggedIn(cookieFilter); + + // reset cookies for the login phase + cookieFilter = new CookieFilter(); + // now try to log in + challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + JsonObject login = hardwareKey.makeLoginJson(challenge); + + // now finalise + request = RestAssured + .given() + .filter(cookieFilter); + WebAuthnEndpointHelper.addWebAuthnLoginFormParameters(request, login); + request + .post("/login") + .then().statusCode(200) + .body(Matchers.is("OK")) + .cookie("challenge-cookie", Matchers.is("")) + .cookie("username-cookie", Matchers.is("")) + .cookie("main-cookie", Matchers.notNullValue()); + + // make sure we bumped the user + users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + Assertions.assertEquals(1, users.size()); + Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertEquals(2, users.get(0).getCounter()); + + // make sure our login cookie still works + checkLoggedIn(cookieFilter); + } + + private void checkLoggedIn(CookieFilter cookieFilter) { + RestAssured + .given() + .filter(cookieFilter) + .get("/secure") + .then() + .statusCode(200) + .body(Matchers.is("stef: [admin]")); + RestAssured + .given() + .filter(cookieFilter) + .redirects().follow(false) + .get("/admin").then().statusCode(200).body(Matchers.is("OK")); + RestAssured + .given() + .filter(cookieFilter) + .redirects().follow(false) + .get("/cheese").then().statusCode(403); + } +} diff --git a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java index b9c86917c9a4ad..be602ec2aa4c61 100644 --- a/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java +++ b/extensions/security-webauthn/deployment/src/test/java/io/quarkus/security/webauthn/test/WebAuthnManualTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.security.webauthn.WebAuthnController; import io.quarkus.security.webauthn.WebAuthnUserProvider; import io.quarkus.test.QuarkusUnitTest; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; @@ -61,8 +60,8 @@ public void test() throws Exception { .post("/register") .then().statusCode(200) .body(Matchers.is("OK")) - .cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.is("")) - .cookie(WebAuthnController.USERNAME_COOKIE, Matchers.is("")) + .cookie("_quarkus_webauthn_challenge", Matchers.is("")) + .cookie("_quarkus_webauthn_username", Matchers.is("")) .cookie("quarkus-credential", Matchers.notNullValue()); // make sure we stored the user @@ -89,8 +88,8 @@ public void test() throws Exception { .post("/login") .then().statusCode(200) .body(Matchers.is("OK")) - .cookie(WebAuthnController.CHALLENGE_COOKIE, Matchers.is("")) - .cookie(WebAuthnController.USERNAME_COOKIE, Matchers.is("")) + .cookie("_quarkus_webauthn_challenge", Matchers.is("")) + .cookie("_quarkus_webauthn_username", Matchers.is("")) .cookie("quarkus-credential", Matchers.notNullValue()); // make sure we bumped the user diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java index 50f8a2ad6fb583..b04392ff9385f9 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java @@ -24,9 +24,8 @@ public class WebAuthnController { private static final Logger log = Logger.getLogger(WebAuthnController.class); - public static final String USERNAME_COOKIE = "_quarkus_webauthn_username"; - - public static final String CHALLENGE_COOKIE = "_quarkus_webauthn_challenge"; + private String challengeUsernameCookie; + private String challengeCookie; private WebAuthnSecurity security; @@ -49,6 +48,8 @@ public WebAuthnController(WebAuthnSecurity security, WebAuthnRunTimeConfig confi this.security = security; this.identityProviderManager = identityProviderManager; this.authMech = authMech; + this.challengeCookie = config.challengeCookieName; + this.challengeUsernameCookie = config.challengeUsernameCookieName; } private static boolean containsRequiredString(JsonObject json, String key) { @@ -127,9 +128,9 @@ public void register(RoutingContext ctx) { final JsonObject credentialsOptions = createCredentialsOptions.result(); // save challenge to the session - authMech.getLoginManager().save(credentialsOptions.getString("challenge"), ctx, CHALLENGE_COOKIE, null, + authMech.getLoginManager().save(credentialsOptions.getString("challenge"), ctx, challengeCookie, null, ctx.request().isSSL()); - authMech.getLoginManager().save(webauthnRegister.getString("name"), ctx, USERNAME_COOKIE, null, + authMech.getLoginManager().save(webauthnRegister.getString("name"), ctx, challengeUsernameCookie, null, ctx.request().isSSL()); ok(ctx, credentialsOptions); @@ -174,9 +175,10 @@ public void login(RoutingContext ctx) { final JsonObject getAssertion = generateServerGetAssertion.result(); - authMech.getLoginManager().save(getAssertion.getString("challenge"), ctx, CHALLENGE_COOKIE, null, + authMech.getLoginManager().save(getAssertion.getString("challenge"), ctx, challengeCookie, null, + ctx.request().isSSL()); + authMech.getLoginManager().save(username, ctx, challengeUsernameCookie, null, ctx.request().isSSL()); - authMech.getLoginManager().save(username, ctx, USERNAME_COOKIE, null, ctx.request().isSSL()); ok(ctx, getAssertion); }); @@ -211,8 +213,8 @@ public void callback(RoutingContext ctx) { return; } - RestoreResult challenge = authMech.getLoginManager().restore(ctx, CHALLENGE_COOKIE); - RestoreResult username = authMech.getLoginManager().restore(ctx, USERNAME_COOKIE); + RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie); + RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie); if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty() || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) { ctx.fail(400, new IllegalArgumentException("Missing challenge or username")); @@ -238,8 +240,8 @@ public void callback(RoutingContext ctx) { public void accept(SecurityIdentity identity) { requestContext.destroy(contextState); // invalidate the challenge - WebAuthnSecurity.removeCookie(ctx, WebAuthnController.CHALLENGE_COOKIE); - WebAuthnSecurity.removeCookie(ctx, WebAuthnController.USERNAME_COOKIE); + WebAuthnSecurity.removeCookie(ctx, challengeCookie); + WebAuthnSecurity.removeCookie(ctx, challengeUsernameCookie); try { authMech.getLoginManager().save(identity, ctx, null, ctx.request().isSSL()); ok(ctx); diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java index bba52cf08f4e8c..3a2b7b163c4116 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java @@ -231,6 +231,18 @@ interface RelyingPartyConfig { @WithDefault("quarkus-credential") String cookieName(); + /** + * The cookie that is used to store the challenge data during login/registration + */ + @ConfigItem(defaultValue = "_quarkus_webauthn_challenge") + public String challengeCookieName; + + /** + * The cookie that is used to store the username data during login/registration + */ + @ConfigItem(defaultValue = "_quarkus_webauthn_username") + public String challengeUsernameCookieName; + /** * SameSite attribute for the session cookie. */ diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java index dfb5b7f67f0eb8..0fa76621415ad7 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java @@ -31,6 +31,8 @@ public class WebAuthnSecurity { @Inject WebAuthnAuthenticationMechanism authMech; + private String challengeCookie; + private String challengeUsernameCookie; public WebAuthnSecurity(WebAuthnRunTimeConfig config, Vertx vertx, WebAuthnAuthenticatorStorage database) { // create the webauthn security object @@ -75,6 +77,8 @@ public WebAuthnSecurity(WebAuthnRunTimeConfig config, Vertx vertx, WebAuthnAuthe Origin o = Origin.parse(origin); domain = o.host(); } + this.challengeCookie = config.challengeCookieName; + this.challengeUsernameCookie = config.challengeUsernameCookieName; } /** @@ -86,8 +90,8 @@ public WebAuthnSecurity(WebAuthnRunTimeConfig config, Vertx vertx, WebAuthnAuthe */ public Uni register(WebAuthnRegisterResponse response, RoutingContext ctx) { // validation of the response is done before - RestoreResult challenge = authMech.getLoginManager().restore(ctx, WebAuthnController.CHALLENGE_COOKIE); - RestoreResult username = authMech.getLoginManager().restore(ctx, WebAuthnController.USERNAME_COOKIE); + RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie); + RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie); if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty() || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) { return Uni.createFrom().failure(new RuntimeException("Missing challenge or username")); @@ -103,8 +107,8 @@ public Uni register(WebAuthnRegisterResponse response, RoutingCon .setUsername(username.getPrincipal()) .setWebauthn(response.toJsonObject()), authenticate -> { - removeCookie(ctx, WebAuthnController.CHALLENGE_COOKIE); - removeCookie(ctx, WebAuthnController.USERNAME_COOKIE); + removeCookie(ctx, challengeCookie); + removeCookie(ctx, challengeUsernameCookie); if (authenticate.succeeded()) { // this is registration, so the caller will want to store the created Authenticator, // let's recreate it @@ -125,8 +129,8 @@ public Uni register(WebAuthnRegisterResponse response, RoutingCon */ public Uni login(WebAuthnLoginResponse response, RoutingContext ctx) { // validation of the response is done before - RestoreResult challenge = authMech.getLoginManager().restore(ctx, WebAuthnController.CHALLENGE_COOKIE); - RestoreResult username = authMech.getLoginManager().restore(ctx, WebAuthnController.USERNAME_COOKIE); + RestoreResult challenge = authMech.getLoginManager().restore(ctx, challengeCookie); + RestoreResult username = authMech.getLoginManager().restore(ctx, challengeUsernameCookie); if (challenge == null || challenge.getPrincipal() == null || challenge.getPrincipal().isEmpty() || username == null || username.getPrincipal() == null || username.getPrincipal().isEmpty()) { return Uni.createFrom().failure(new RuntimeException("Missing challenge or username")); @@ -142,8 +146,8 @@ public Uni login(WebAuthnLoginResponse response, RoutingContext c .setUsername(username.getPrincipal()) .setWebauthn(response.toJsonObject()), authenticate -> { - removeCookie(ctx, WebAuthnController.CHALLENGE_COOKIE); - removeCookie(ctx, WebAuthnController.USERNAME_COOKIE); + removeCookie(ctx, challengeCookie); + removeCookie(ctx, challengeUsernameCookie); if (authenticate.succeeded()) { // this is login, so the user will want to bump the counter // FIXME: do we need the auth here? likely the user will know it and will just ++ on the DB-stored counter, no? diff --git a/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java b/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java index 73fbd671b9abb1..44171258e46756 100644 --- a/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java +++ b/integration-tests/security-webauthn/src/test/java/io/quarkus/it/security/webauthn/test/WebAuthnResourceTest.java @@ -7,7 +7,6 @@ import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; -import io.quarkus.security.webauthn.WebAuthnController; import io.quarkus.test.junit.QuarkusTest; import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; import io.quarkus.test.security.webauthn.WebAuthnHardware; @@ -99,9 +98,9 @@ private void invokeCustomEndpoint(String uri, Filter cookieFilter, Consumer Date: Wed, 24 Jan 2024 15:52:27 +0100 Subject: [PATCH 04/25] WebAuthn docs: make sure we advertise the encryption config when missing Because users get confused as to where to configure it. I also added it to the docs. --- .../java/io/quarkus/security/webauthn/WebAuthnRecorder.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java index 19f3ee77e14871..03b2d65e1f2b50 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRecorder.java @@ -61,7 +61,9 @@ public WebAuthnAuthenticationMechanism get() { byte[] data = new byte[32]; new SecureRandom().nextBytes(data); key = encryptionKey = Base64.getEncoder().encodeToString(data); - log.warn("Encryption key was not specified for persistent WebAuthn auth, using temporary key " + key); + log.warn( + "Encryption key was not specified (using `quarkus.http.auth.session.encryption-key` configuration) for persistent WebAuthn auth, using temporary key " + + key); } } else { key = httpConfiguration.getValue().encryptionKey.get(); From 5e46c8cb411497377f677ab068b2f143fbb646cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Wed, 24 Jan 2024 17:34:10 +0100 Subject: [PATCH 05/25] WebAuthn: support running on virtual thread Fixes #38352 --- docs/src/main/asciidoc/security-webauthn.adoc | 9 ++ .../WebAuthnAuthenticatorStorage.java | 30 ++++- integration-tests/virtual-threads/pom.xml | 1 + .../security-webauthn-virtual-threads/pom.xml | 105 ++++++++++++++++++ .../security/webauthn/TestResource.java | 49 ++++++++ ...WebAuthnVirtualThreadTestUserProvider.java | 52 +++++++++ .../src/main/resources/application.properties | 0 .../webauthn/RunOnVirtualThreadIT.java | 8 ++ .../webauthn/RunOnVirtualThreadTest.java | 103 +++++++++++++++++ 9 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml create mode 100644 integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/TestResource.java create mode 100644 integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java create mode 100644 integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties create mode 100644 integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java create mode 100644 integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java diff --git a/docs/src/main/asciidoc/security-webauthn.adoc b/docs/src/main/asciidoc/security-webauthn.adoc index 67ac942f5bc939..21fde7010e7607 100644 --- a/docs/src/main/asciidoc/security-webauthn.adoc +++ b/docs/src/main/asciidoc/security-webauthn.adoc @@ -1024,6 +1024,15 @@ data access with your `WebAuthnUserProvider`. You will have to add the `@Blocking` annotation on your `WebAuthnUserProvider` class in order to tell the Quarkus WebAuthn endpoints to defer those calls to the worker pool. +== Virtual-Threads version + +If you're using a blocking data access to the database, you can safely block on the `WebAuthnSecurity` methods, +with `.await().indefinitely()`, because nothing is async in the `register` and `login` methods, besides the +data access with your `WebAuthnUserProvider`. + +You will have to add the `@RunOnVirtualThread` annotation on your `WebAuthnUserProvider` class in order to tell the +Quarkus WebAuthn endpoints to defer those calls to virtual threads. + == Testing WebAuthn Testing WebAuthn can be complicated because normally you need a hardware token, which is why we've made the diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java index 6ae86cc80ef8db..ef680306535cbb 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnAuthenticatorStorage.java @@ -8,8 +8,10 @@ import jakarta.inject.Inject; import io.quarkus.runtime.BlockingOperationControl; +import io.quarkus.virtual.threads.VirtualThreadsRecorder; import io.smallrye.common.annotation.Blocking; import io.smallrye.common.annotation.NonBlocking; +import io.smallrye.common.annotation.RunOnVirtualThread; import io.smallrye.mutiny.Uni; import io.vertx.core.Future; import io.vertx.ext.auth.webauthn.Authenticator; @@ -39,18 +41,38 @@ else if (query.getCredID() != null) } @SuppressWarnings({ "rawtypes", "unchecked" }) - private Uni runPotentiallyBlocking(Supplier> supplier) { + private Uni runPotentiallyBlocking(Supplier> supplier) { if (BlockingOperationControl.isBlockingAllowed() - || !isBlocking(userProvider.getClass())) - return supplier.get(); + || isNonBlocking(userProvider.getClass())) { + return (Uni) supplier.get(); + } + if (isRunOnVirtualThread(userProvider.getClass())) { + return Uni.createFrom().deferred(supplier).runSubscriptionOn(VirtualThreadsRecorder.getCurrent()); + } // run it in a worker thread return vertx.executeBlocking(Uni.createFrom().deferred((Supplier) supplier)); } - private boolean isBlocking(Class klass) { + private boolean isNonBlocking(Class klass) { do { + if (klass.isAnnotationPresent(NonBlocking.class)) + return true; if (klass.isAnnotationPresent(Blocking.class)) + return false; + if (klass.isAnnotationPresent(RunOnVirtualThread.class)) + return false; + klass = klass.getSuperclass(); + } while (klass != null); + // no information, assumed non-blocking + return true; + } + + private boolean isRunOnVirtualThread(Class klass) { + do { + if (klass.isAnnotationPresent(RunOnVirtualThread.class)) return true; + if (klass.isAnnotationPresent(Blocking.class)) + return false; if (klass.isAnnotationPresent(NonBlocking.class)) return false; klass = klass.getSuperclass(); diff --git a/integration-tests/virtual-threads/pom.xml b/integration-tests/virtual-threads/pom.xml index 586794a724a9be..faf5080403d286 100644 --- a/integration-tests/virtual-threads/pom.xml +++ b/integration-tests/virtual-threads/pom.xml @@ -36,6 +36,7 @@ quartz-virtual-threads virtual-threads-disabled reactive-routes-virtual-threads + security-webauthn-virtual-threads diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml b/integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml new file mode 100644 index 00000000000000..60b8914ed5a582 --- /dev/null +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml @@ -0,0 +1,105 @@ + + + 4.0.0 + + + quarkus-virtual-threads-integration-tests-parent + io.quarkus + 999-SNAPSHOT + + + quarkus-integration-test-virtual-threads-security-webauthn + Quarkus - Integration Tests - Virtual Threads - Security WebAuthn + + + + io.quarkus + quarkus-resteasy-reactive-jackson + + + io.quarkus + quarkus-security-webauthn + + + + io.quarkus + quarkus-test-vertx + + + io.quarkus + quarkus-junit5 + test + + + + io.quarkus + quarkus-test-security-webauthn + + + io.quarkus.junit5 + junit5-virtual-threads + test + + + io.rest-assured + rest-assured + test + + + org.awaitility + awaitility + test + + + org.assertj + assertj-core + test + + + + + io.quarkus + quarkus-resteasy-reactive-jackson-deployment + ${project.version} + pom + test + + + * + * + + + + + + + io.quarkus + quarkus-security-webauthn-deployment + ${project.version} + pom + test + + + * + * + + + + + + + + + io.quarkus + quarkus-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + + + diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/TestResource.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/TestResource.java new file mode 100644 index 00000000000000..0789853a81a65d --- /dev/null +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/TestResource.java @@ -0,0 +1,49 @@ +package io.quarkus.virtual.security.webauthn; + +import jakarta.annotation.security.RolesAllowed; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import io.quarkus.test.vertx.VirtualThreadsAssertions; +import io.smallrye.common.annotation.RunOnVirtualThread; + +@RunOnVirtualThread +@Path("/") +public class TestResource { + @Inject + SecurityIdentity identity; + + @Authenticated + @Path("secure") + @GET + public String getUserName() { + VirtualThreadsAssertions.assertEverything(); + return identity.getPrincipal().getName() + ": " + identity.getRoles(); + } + + @RolesAllowed("admin") + @Path("admin") + @GET + public String getAdmin() { + VirtualThreadsAssertions.assertEverything(); + return "OK"; + } + + @RolesAllowed("cheese") + @Path("cheese") + @GET + public String getCheese() { + VirtualThreadsAssertions.assertEverything(); + return "OK"; + } + + @Path("open") + @GET + public String hello() { + VirtualThreadsAssertions.assertEverything(); + return "Hello"; + } +} diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java new file mode 100644 index 00000000000000..7c7250eb126073 --- /dev/null +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/java/io/quarkus/virtual/security/webauthn/WebAuthnVirtualThreadTestUserProvider.java @@ -0,0 +1,52 @@ +package io.quarkus.virtual.security.webauthn; + +import java.util.List; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.test.security.webauthn.WebAuthnTestUserProvider; +import io.quarkus.test.vertx.VirtualThreadsAssertions; +import io.smallrye.common.annotation.RunOnVirtualThread; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.auth.webauthn.Authenticator; + +/** + * This UserProvider stores and updates the credentials in the callback endpoint, but is blocking + */ +@ApplicationScoped +@RunOnVirtualThread +public class WebAuthnVirtualThreadTestUserProvider extends WebAuthnTestUserProvider { + @Override + public Uni> findWebAuthnCredentialsByCredID(String credId) { + assertVirtualThread(); + return super.findWebAuthnCredentialsByCredID(credId); + } + + @Override + public Uni> findWebAuthnCredentialsByUserName(String userId) { + assertVirtualThread(); + return super.findWebAuthnCredentialsByUserName(userId); + } + + @Override + public Uni updateOrStoreWebAuthnCredentials(Authenticator authenticator) { + assertVirtualThread(); + return super.updateOrStoreWebAuthnCredentials(authenticator); + } + + private void assertVirtualThread() { + // allow this being used in the tests + if (isTestThread()) + return; + VirtualThreadsAssertions.assertEverything(); + } + + static boolean isTestThread() { + for (StackTraceElement stackTraceElement : Thread.currentThread().getStackTrace()) { + if (stackTraceElement.getClassName().equals("io.quarkus.test.junit.QuarkusTestExtension")) + return true; + } + return false; + } + +} diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/main/resources/application.properties new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java new file mode 100644 index 00000000000000..c834a4ca976540 --- /dev/null +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadIT.java @@ -0,0 +1,8 @@ +package io.quarkus.virtual.security.webauthn; + +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +class RunOnVirtualThreadIT extends RunOnVirtualThreadTest { + +} diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java new file mode 100644 index 00000000000000..2cbd9f85afd5d0 --- /dev/null +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/src/test/java/io/quarkus/virtual/security/webauthn/RunOnVirtualThreadTest.java @@ -0,0 +1,103 @@ +package io.quarkus.virtual.security.webauthn; + +import static org.hamcrest.Matchers.is; + +import java.util.List; + +import jakarta.inject.Inject; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import io.quarkus.security.webauthn.WebAuthnUserProvider; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit5.virtual.ShouldNotPin; +import io.quarkus.test.junit5.virtual.VirtualThreadUnit; +import io.quarkus.test.security.webauthn.WebAuthnEndpointHelper; +import io.quarkus.test.security.webauthn.WebAuthnHardware; +import io.restassured.RestAssured; +import io.restassured.filter.cookie.CookieFilter; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.auth.webauthn.Authenticator; + +@QuarkusTest +@VirtualThreadUnit +@ShouldNotPin +class RunOnVirtualThreadTest { + + @Inject + WebAuthnUserProvider userProvider; + + @Test + public void test() throws Exception { + + RestAssured.get("/open").then().statusCode(200).body(Matchers.is("Hello")); + RestAssured + .given().redirects().follow(false) + .get("/secure").then().statusCode(302); + RestAssured + .given().redirects().follow(false) + .get("/admin").then().statusCode(302); + RestAssured + .given().redirects().follow(false) + .get("/cheese").then().statusCode(302); + + Assertions.assertTrue(userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely().isEmpty()); + CookieFilter cookieFilter = new CookieFilter(); + WebAuthnHardware hardwareKey = new WebAuthnHardware(); + String challenge = WebAuthnEndpointHelper.invokeRegistration("stef", cookieFilter); + JsonObject registration = hardwareKey.makeRegistrationJson(challenge); + + // now finalise + WebAuthnEndpointHelper.invokeCallback(registration, cookieFilter); + + // make sure we stored the user + List users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + Assertions.assertEquals(1, users.size()); + Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertEquals(1, users.get(0).getCounter()); + + // make sure our login cookie works + checkLoggedIn(cookieFilter); + + // reset cookies for the login phase + cookieFilter = new CookieFilter(); + // now try to log in + challenge = WebAuthnEndpointHelper.invokeLogin("stef", cookieFilter); + JsonObject login = hardwareKey.makeLoginJson(challenge); + + // now finalise + WebAuthnEndpointHelper.invokeCallback(login, cookieFilter); + + // make sure we bumped the user + users = userProvider.findWebAuthnCredentialsByUserName("stef").await().indefinitely(); + Assertions.assertEquals(1, users.size()); + Assertions.assertTrue(users.get(0).getUserName().equals("stef")); + Assertions.assertEquals(2, users.get(0).getCounter()); + + // make sure our login cookie still works + checkLoggedIn(cookieFilter); + } + + private void checkLoggedIn(CookieFilter cookieFilter) { + RestAssured + .given() + .filter(cookieFilter) + .get("/secure") + .then() + .statusCode(200) + .body(Matchers.is("stef: [admin]")); + RestAssured + .given() + .filter(cookieFilter) + .redirects().follow(false) + .get("/admin").then().statusCode(200).body(Matchers.is("OK")); + RestAssured + .given() + .filter(cookieFilter) + .redirects().follow(false) + .get("/cheese").then().statusCode(403); + } + +} From 5777a0bc551eeb35c7cdd29c378108b49989d3c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 26 Jan 2024 16:50:54 +0100 Subject: [PATCH 06/25] WebAuthn config: document the proper default value Fixes #38402 --- .../quarkus/security/webauthn/WebAuthnController.java | 4 ++-- .../security/webauthn/WebAuthnRunTimeConfig.java | 10 +++++----- .../io/quarkus/security/webauthn/WebAuthnSecurity.java | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java index b04392ff9385f9..0c7894568bcbab 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnController.java @@ -48,8 +48,8 @@ public WebAuthnController(WebAuthnSecurity security, WebAuthnRunTimeConfig confi this.security = security; this.identityProviderManager = identityProviderManager; this.authMech = authMech; - this.challengeCookie = config.challengeCookieName; - this.challengeUsernameCookie = config.challengeUsernameCookieName; + this.challengeCookie = config.challengeCookieName(); + this.challengeUsernameCookie = config.challengeUsernameCookieName(); } private static boolean containsRequiredString(JsonObject json, String key) { diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java index 3a2b7b163c4116..9f118893ee2b87 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnRunTimeConfig.java @@ -104,7 +104,7 @@ enum CookieSameSite { *
  • {@code DISCOURAGED} - User should avoid interact with the browser
  • * */ - @ConfigDocDefault("REQUIRED") + @ConfigDocDefault("DISCOURAGED") Optional userVerification(); /** @@ -234,14 +234,14 @@ interface RelyingPartyConfig { /** * The cookie that is used to store the challenge data during login/registration */ - @ConfigItem(defaultValue = "_quarkus_webauthn_challenge") - public String challengeCookieName; + @WithDefault("_quarkus_webauthn_challenge") + public String challengeCookieName(); /** * The cookie that is used to store the username data during login/registration */ - @ConfigItem(defaultValue = "_quarkus_webauthn_username") - public String challengeUsernameCookieName; + @WithDefault("_quarkus_webauthn_username") + public String challengeUsernameCookieName(); /** * SameSite attribute for the session cookie. diff --git a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java index 0fa76621415ad7..d803512ea5a340 100644 --- a/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java +++ b/extensions/security-webauthn/runtime/src/main/java/io/quarkus/security/webauthn/WebAuthnSecurity.java @@ -77,8 +77,8 @@ public WebAuthnSecurity(WebAuthnRunTimeConfig config, Vertx vertx, WebAuthnAuthe Origin o = Origin.parse(origin); domain = o.host(); } - this.challengeCookie = config.challengeCookieName; - this.challengeUsernameCookie = config.challengeUsernameCookieName; + this.challengeCookie = config.challengeCookieName(); + this.challengeUsernameCookie = config.challengeUsernameCookieName(); } /** From 7809847dd0223866ed79f7680f3501f3ede9ab92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 26 Jan 2024 17:29:48 +0100 Subject: [PATCH 07/25] WebAuthn: add note about https --- docs/src/main/asciidoc/security-webauthn.adoc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/src/main/asciidoc/security-webauthn.adoc b/docs/src/main/asciidoc/security-webauthn.adoc index 21fde7010e7607..450fc1ee921715 100644 --- a/docs/src/main/asciidoc/security-webauthn.adoc +++ b/docs/src/main/asciidoc/security-webauthn.adoc @@ -67,6 +67,10 @@ Just in case you get there wondering what's the relation with https://fidoallian and whether we support it: sure, yes, PassKeys is a way that your authenticator devices can share and sync their credentials, which you can then use with our WebAuthn authentication. +NOTE: The WebAuthn specification requires `https` to be used for communication with the server, though +some browsers allow `localhost`. If you must use `https` in `DEV` mode, you can always use the +https://docs.quarkiverse.io/quarkus-ngrok/dev/index.html[quarkus-ngrok] extension. + == Architecture In this example, we build a very simple microservice which offers four endpoints: From 4597b00388885550dac3833cb559df20f4d9453a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 26 Jan 2024 17:30:02 +0100 Subject: [PATCH 08/25] WebAuthn: reorder login/register/callback example payloads So it's more logical: register->callback, login->callback --- docs/src/main/asciidoc/security-webauthn.adoc | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/src/main/asciidoc/security-webauthn.adoc b/docs/src/main/asciidoc/security-webauthn.adoc index 450fc1ee921715..2483ff01c08abf 100644 --- a/docs/src/main/asciidoc/security-webauthn.adoc +++ b/docs/src/main/asciidoc/security-webauthn.adoc @@ -723,6 +723,26 @@ The Quarkus WebAuthn extension comes out of the box with these REST endpoints pr } ---- +=== Trigger a registration + +`POST /q/webauthn/callback`: Trigger a registration + +[source,json] +.Request +---- +{ + "id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg", + "rawId": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg", + "response": { + "attestationObject": "", + "clientDataJSON":"" + }, + "type": "public-key" +} +---- + +This returns a 204 with no body. + === Obtain a login challenge `POST /q/webauthn/login`: Set up and obtain a login challenge @@ -762,26 +782,6 @@ The Quarkus WebAuthn extension comes out of the box with these REST endpoints pr } ---- -=== Trigger a registration - -`POST /q/webauthn/callback`: Trigger a registration - -[source,json] -.Request ----- -{ - "id": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg", - "rawId": "boMwU-QwZ_RsToPTG3iC50g8-yiKbLc3A53tgWMhzbNEQAJIlbWgchmwbt5m0ssqQNR0IM_WxCmcfKWlEao7Fg", - "response": { - "attestationObject": "", - "clientDataJSON":"" - }, - "type": "public-key" -} ----- - -This returns a 204 with no body. - === Trigger a login `POST /q/webauthn/callback`: Trigger a login From e08da06b49e7eeb9c111ff3b000a5e004f6eaddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 26 Jan 2024 17:31:17 +0100 Subject: [PATCH 09/25] WebAuthn: attempt to clarify why we override callback for login/register And that we're not overriding the challenge part --- docs/src/main/asciidoc/security-webauthn.adoc | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/src/main/asciidoc/security-webauthn.adoc b/docs/src/main/asciidoc/security-webauthn.adoc index 2483ff01c08abf..1edac265a39eeb 100644 --- a/docs/src/main/asciidoc/security-webauthn.adoc +++ b/docs/src/main/asciidoc/security-webauthn.adoc @@ -921,7 +921,13 @@ to the server to your custom login or registration endpoints. If you are storing them in form input elements, you can then use the `WebAuthnLoginResponse` and `WebAuthnRegistrationResponse` classes, mark them as `@BeanParam` and then use the `WebAuthnSecurity.login` -and `WebAuthnSecurity.register` methods. For example, here's how you can handle a custom login and register: +and `WebAuthnSecurity.register` methods to replace the `/q/webauthn/callback` endpoint. This even +allows you to create two separate endpoints for handling login and registration at different endpoints. + +In most cases you can keep using the `/q/webauthn/login` and `/q/webauthn/register` challenge-initiating +endpoints, because this is not where custom logic is required. + +For example, here's how you can handle a custom login and register action: [source,java] ---- @@ -949,6 +955,7 @@ public class LoginResource { @Inject WebAuthnSecurity webAuthnSecurity; + // Provide an alternative implementation of the /q/webauthn/callback endpoint, only for login @Path("/login") @POST @Transactional @@ -978,12 +985,13 @@ public class LoginResource { } } + // Provide an alternative implementation of the /q/webauthn/callback endpoint, only for registration @Path("/register") @POST @Transactional public Response register(@RestForm String userName, - @BeanParam WebAuthnRegisterResponse webAuthnResponse, - RoutingContext ctx) { + @BeanParam WebAuthnRegisterResponse webAuthnResponse, + RoutingContext ctx) { // Input validation if(userName == null || userName.isEmpty() || !webAuthnResponse.isSet() || !webAuthnResponse.isValid()) { return Response.status(Status.BAD_REQUEST).build(); From 19b4ae65078d29f9dc09e4a0a22d1d29974f6b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Fri, 26 Jan 2024 17:47:06 +0100 Subject: [PATCH 10/25] WebAuthn: add cause to JS errors Fixes #38400 --- .../runtime/src/main/resources/webauthn.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/security-webauthn/runtime/src/main/resources/webauthn.js b/extensions/security-webauthn/runtime/src/main/resources/webauthn.js index c2a2ef659e26f6..e38b2982fc23e1 100644 --- a/extensions/security-webauthn/runtime/src/main/resources/webauthn.js +++ b/extensions/security-webauthn/runtime/src/main/resources/webauthn.js @@ -123,7 +123,7 @@ if (res.status === 200) { return res; } - throw new Error(res.statusText); + throw new Error(res.statusText, {cause: res}); }) .then(res => res.json()) .then(res => { @@ -167,7 +167,7 @@ if (res.status >= 200 && res.status < 300) { return res; } - throw new Error(res.statusText); + throw new Error(res.statusText, {cause: res}); }); }; @@ -188,7 +188,7 @@ if (res.status >= 200 && res.status < 300) { return res; } - throw new Error(res.statusText); + throw new Error(res.statusText, {cause: res}); }); }; @@ -209,7 +209,7 @@ if (res.status === 200) { return res; } - throw new Error(res.statusText); + throw new Error(res.statusText, {cause: res}); }) .then(res => res.json()) .then(res => { From 82efe40dbe510e15417177dc7b6a9ee7ff76bebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20=C3=89pardaud?= Date: Thu, 21 Mar 2024 11:36:51 +0100 Subject: [PATCH 11/25] Make CI happy? --- .../security-webauthn-virtual-threads/pom.xml | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml b/integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml index 60b8914ed5a582..0431c2f4ed2d5c 100644 --- a/integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml +++ b/integration-tests/virtual-threads/security-webauthn-virtual-threads/pom.xml @@ -58,21 +58,6 @@ test - - - io.quarkus - quarkus-resteasy-reactive-jackson-deployment - ${project.version} - pom - test - - - * - * - - - - io.quarkus From b89706d693ada6b440ea367e6357d00f7ff7cad5 Mon Sep 17 00:00:00 2001 From: Marc Nuri Date: Mon, 25 Mar 2024 15:51:25 +0100 Subject: [PATCH 12/25] deps: Bump kubernetes-client-bom from 6.10.0 to 6.11.0 Signed-off-by: Marc Nuri --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a9cc270f2e7023..dcf33c61fdc2cd 100644 --- a/pom.xml +++ b/pom.xml @@ -64,7 +64,7 @@ 0.8.11 - 6.10.0 + 6.11.0 1.62.2 From a6be47ab51883f183d7d39a2bc5e59fbd5679032 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Mon, 25 Mar 2024 17:48:59 +0000 Subject: [PATCH 13/25] Bump mime4j version to 0.8.11 --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index dd97dd62a753d1..56a1c47fdcf64a 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -223,7 +223,7 @@ 0.0.6 0.1.3 2.12.0 - 0.8.9 + 0.8.11 1.0.0 3.0.0 2.12.3 From 8e14c35100c312c2c7caca2f69a713e4577b7bfa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Mar 2024 23:08:09 +0000 Subject: [PATCH 14/25] Bump artemis.version from 2.32.0 to 2.33.0 Bumps `artemis.version` from 2.32.0 to 2.33.0. Updates `org.apache.activemq:artemis-server` from 2.32.0 to 2.33.0 Updates `org.apache.activemq:artemis-amqp-protocol` from 2.32.0 to 2.33.0 --- updated-dependencies: - dependency-name: org.apache.activemq:artemis-server dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.apache.activemq:artemis-amqp-protocol dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- build-parent/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-parent/pom.xml b/build-parent/pom.xml index 8118ce3c3b7aac..6f147f0fafe361 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -120,7 +120,7 @@ 7.3.0 - 2.32.0 + 2.33.0 From c46a03fd5227a6554f229c1aece26c91448f886e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Mar 2024 23:22:58 +0000 Subject: [PATCH 15/25] Bump asm.version from 9.6 to 9.7 Bumps `asm.version` from 9.6 to 9.7. Updates `org.ow2.asm:asm` from 9.6 to 9.7 Updates `org.ow2.asm:asm-commons` from 9.6 to 9.7 Updates `org.ow2.asm:asm-tree` from 9.6 to 9.7 Updates `org.ow2.asm:asm-analysis` from 9.6 to 9.7 Updates `org.ow2.asm:asm-util` from 9.6 to 9.7 --- updated-dependencies: - dependency-name: org.ow2.asm:asm dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.ow2.asm:asm-commons dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.ow2.asm:asm-tree dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.ow2.asm:asm-analysis dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.ow2.asm:asm-util dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- bom/application/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/application/pom.xml b/bom/application/pom.xml index 3c0f2cefe1b489..91a2bae123e3a5 100644 --- a/bom/application/pom.xml +++ b/bom/application/pom.xml @@ -84,7 +84,7 @@ 3.1.0 4.0.2 4.0.5 - 9.6 + 9.7 2.15.1 16.0.0.Final 3.0-alpha-2 From 2119957cbadd735f1408cbca5072b88dad91ad9a Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Thu, 21 Mar 2024 14:01:24 +0200 Subject: [PATCH 16/25] Allow JsonObject and JsonArray to be used in any POJO for JSON handling Closes: #39599 --- .../deployment/test/VertxJsonEndpoint.java | 32 +++++++++++++++++++ .../deployment/test/VertxJsonTest.java | 28 ++++++++++++++++ extensions/vertx/deployment/pom.xml | 4 +++ .../vertx/deployment/VertxJsonProcessor.java | 19 +++++++++++ .../runtime/jackson/BufferDeserializer.java | 2 +- .../runtime/jackson/BufferSerializer.java | 2 +- .../jackson/JsonArrayDeserializer.java | 30 +++++++++++++++++ .../runtime/jackson/JsonArraySerializer.java | 2 +- .../jackson/JsonObjectDeserializer.java | 30 +++++++++++++++++ .../runtime/jackson/JsonObjectSerializer.java | 2 +- 10 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArrayDeserializer.java create mode 100644 extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectDeserializer.java diff --git a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonEndpoint.java b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonEndpoint.java index c8052f728476a8..e2330472a2eba9 100644 --- a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonEndpoint.java +++ b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonEndpoint.java @@ -27,6 +27,20 @@ public JsonObject jsonObject(JsonObject input) { return result; } + @POST + @Path("jsonObjectWrapper") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public JsonObjectWrapper jsonObjectWrapper(JsonObjectWrapper wrapper) { + var payload = wrapper.payload; + JsonObject result = new JsonObject(); + result.put("name", payload.getString("name")); + result.put("age", 50); + result.put("nested", new JsonObject(Collections.singletonMap("foo", "bar"))); + result.put("bools", new JsonArray().add(true)); + return new JsonObjectWrapper(result); + } + @POST @Path("jsonArray") @Produces(MediaType.APPLICATION_JSON) @@ -36,4 +50,22 @@ public JsonArray jsonArray(JsonArray input) { result.add("last"); return result; } + + @POST + @Path("jsonArrayWrapper") + @Produces(MediaType.APPLICATION_JSON) + @Consumes(MediaType.APPLICATION_JSON) + public JsonArrayWrapper jsonArrayWrapper(JsonArrayWrapper wrapper) { + var payload = wrapper.payload; + JsonArray result = payload.copy(); + result.add("last"); + return new JsonArrayWrapper(result); + } + + public record JsonObjectWrapper(JsonObject payload) { + } + + public record JsonArrayWrapper(JsonArray payload) { + + } } diff --git a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonTest.java b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonTest.java index a62fe11749f187..9756d7c3144ed9 100644 --- a/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonTest.java +++ b/extensions/resteasy-reactive/rest-jackson/deployment/src/test/java/io/quarkus/resteasy/reactive/jackson/deployment/test/VertxJsonTest.java @@ -38,6 +38,21 @@ public void testJsonObject() { .body("bools[0]", Matchers.equalTo(true)); } + @Test + public void testJsonObjectWrapper() { + RestAssured.with() + .body("{\"payload\": {\"name\": \"Bob\"}}") + .contentType("application/json") + .post("/vertx/jsonObjectWrapper") + .then() + .statusCode(200) + .contentType("application/json") + .body("payload.name", Matchers.equalTo("Bob")) + .body("payload.age", Matchers.equalTo(50)) + .body("payload.nested.foo", Matchers.equalTo("bar")) + .body("payload.bools[0]", Matchers.equalTo(true)); + } + @Test public void testJsonArray() { RestAssured.with() @@ -51,4 +66,17 @@ public void testJsonArray() { .body("[1]", Matchers.equalTo("last")); } + @Test + public void testJsonArrayWrapper() { + RestAssured.with() + .body("{\"payload\": [\"first\"]}") + .contentType("application/json") + .post("/vertx/jsonArrayWrapper") + .then() + .statusCode(200) + .contentType("application/json") + .body("payload[0]", Matchers.equalTo("first")) + .body("payload[1]", Matchers.equalTo("last")); + } + } diff --git a/extensions/vertx/deployment/pom.xml b/extensions/vertx/deployment/pom.xml index aad294e6680e0f..fd96dfafdae571 100644 --- a/extensions/vertx/deployment/pom.xml +++ b/extensions/vertx/deployment/pom.xml @@ -33,6 +33,10 @@ io.quarkus quarkus-mutiny-deployment + + io.quarkus + quarkus-jackson-spi + io.quarkus quarkus-junit5-internal diff --git a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxJsonProcessor.java b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxJsonProcessor.java index cbecc141c135ee..2708d6d25c9768 100644 --- a/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxJsonProcessor.java +++ b/extensions/vertx/deployment/src/main/java/io/quarkus/vertx/deployment/VertxJsonProcessor.java @@ -6,6 +6,13 @@ import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.builditem.nativeimage.RuntimeReinitializedClassBuildItem; import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; +import io.quarkus.jackson.spi.JacksonModuleBuildItem; +import io.quarkus.vertx.runtime.jackson.JsonArrayDeserializer; +import io.quarkus.vertx.runtime.jackson.JsonArraySerializer; +import io.quarkus.vertx.runtime.jackson.JsonObjectDeserializer; +import io.quarkus.vertx.runtime.jackson.JsonObjectSerializer; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; import io.vertx.core.spi.JsonFactory; public class VertxJsonProcessor { @@ -24,4 +31,16 @@ void nativeSupport(List reinitializeVertxJson, serviceProviderBuildItemBuildProducer .produce(ServiceProviderBuildItem.allProvidersFromClassPath(JsonFactory.class.getName())); } + + @BuildStep + JacksonModuleBuildItem registerJacksonSerDeser() { + return new JacksonModuleBuildItem.Builder("VertxTypes") + .add(JsonArraySerializer.class.getName(), + JsonArrayDeserializer.class.getName(), + JsonArray.class.getName()) + .add(JsonObjectSerializer.class.getName(), + JsonObjectDeserializer.class.getName(), + JsonObject.class.getName()) + .build(); + } } diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferDeserializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferDeserializer.java index 696f7182feeab4..1ae4c81345b74d 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferDeserializer.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferDeserializer.java @@ -25,7 +25,7 @@ /** * Copied from {@code io.vertx.core.json.jackson.BufferDeserializer} as that class is package private */ -class BufferDeserializer extends JsonDeserializer { +public class BufferDeserializer extends JsonDeserializer { @Override public Buffer deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException { diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferSerializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferSerializer.java index 508c59083d911b..364fbd71eeb310 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferSerializer.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/BufferSerializer.java @@ -23,7 +23,7 @@ /** * Copied from {@code io.vertx.core.json.jackson.BufferSerializer} as that class is package private */ -class BufferSerializer extends JsonSerializer { +public class BufferSerializer extends JsonSerializer { @Override public void serialize(Buffer value, JsonGenerator jgen, SerializerProvider provider) throws IOException { diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArrayDeserializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArrayDeserializer.java new file mode 100644 index 00000000000000..59dc87c0097a28 --- /dev/null +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArrayDeserializer.java @@ -0,0 +1,30 @@ +package io.quarkus.vertx.runtime.jackson; + +import java.util.List; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDelegatingDeserializer; +import com.fasterxml.jackson.databind.util.Converter; +import com.fasterxml.jackson.databind.util.StdConverter; + +import io.vertx.core.json.JsonArray; + +public class JsonArrayDeserializer extends StdDelegatingDeserializer { + + public JsonArrayDeserializer() { + super(new StdConverter, JsonArray>() { + @Override + public JsonArray convert(List list) { + return new JsonArray(list); + } + }); + } + + @Override + protected StdDelegatingDeserializer withDelegate(Converter converter, + JavaType delegateType, + JsonDeserializer delegateDeserializer) { + return new StdDelegatingDeserializer<>(converter, delegateType, delegateDeserializer); + } +} diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArraySerializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArraySerializer.java index dc2053b32530b6..1c3575152df1ea 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArraySerializer.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonArraySerializer.java @@ -22,7 +22,7 @@ /** * Copied from {@code io.vertx.core.json.jackson.JsonArraySerializer} as that class is package private */ -class JsonArraySerializer extends JsonSerializer { +public class JsonArraySerializer extends JsonSerializer { @Override public void serialize(JsonArray value, JsonGenerator jgen, SerializerProvider provider) throws IOException { jgen.writeObject(value.getList()); diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectDeserializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectDeserializer.java new file mode 100644 index 00000000000000..8dfba6c2c181b5 --- /dev/null +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectDeserializer.java @@ -0,0 +1,30 @@ +package io.quarkus.vertx.runtime.jackson; + +import java.util.Map; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.deser.std.StdDelegatingDeserializer; +import com.fasterxml.jackson.databind.util.Converter; +import com.fasterxml.jackson.databind.util.StdConverter; + +import io.vertx.core.json.JsonObject; + +public class JsonObjectDeserializer extends StdDelegatingDeserializer { + + public JsonObjectDeserializer() { + super(new StdConverter, JsonObject>() { + @Override + public JsonObject convert(Map map) { + return new JsonObject(map); + } + }); + } + + @Override + protected StdDelegatingDeserializer withDelegate(Converter converter, + JavaType delegateType, + JsonDeserializer delegateDeserializer) { + return new StdDelegatingDeserializer<>(converter, delegateType, delegateDeserializer); + } +} diff --git a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectSerializer.java b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectSerializer.java index 07bfed2083ede1..e276b09bc36b10 100644 --- a/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectSerializer.java +++ b/extensions/vertx/runtime/src/main/java/io/quarkus/vertx/runtime/jackson/JsonObjectSerializer.java @@ -21,7 +21,7 @@ /** * Copied from {@code io.vertx.core.json.jackson.JsonObjectSerializer} as that class is package private */ -class JsonObjectSerializer extends JsonSerializer { +public class JsonObjectSerializer extends JsonSerializer { @Override public void serialize(JsonObject value, JsonGenerator jgen, SerializerProvider provider) throws IOException { jgen.writeObject(value.getMap()); From 70727f113ab750da2f460a0a1226773f2edabf5b Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Tue, 26 Mar 2024 08:03:34 +0200 Subject: [PATCH 17/25] Increase count of reflected types --- .../test/resources/image-metrics/23.1/image-metrics.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-tests/jpa-postgresql/src/test/resources/image-metrics/23.1/image-metrics.properties b/integration-tests/jpa-postgresql/src/test/resources/image-metrics/23.1/image-metrics.properties index 6315f449e1bf99..507eb65104e094 100644 --- a/integration-tests/jpa-postgresql/src/test/resources/image-metrics/23.1/image-metrics.properties +++ b/integration-tests/jpa-postgresql/src/test/resources/image-metrics/23.1/image-metrics.properties @@ -7,7 +7,7 @@ analysis_results.methods.reachable=96465 analysis_results.methods.reachable.tolerance=3 analysis_results.fields.reachable=27025 analysis_results.fields.reachable.tolerance=3 -analysis_results.types.reflection=6048 +analysis_results.types.reflection=6100 analysis_results.types.reflection.tolerance=3 analysis_results.methods.reflection=4707 analysis_results.methods.reflection.tolerance=3 From 8e353b4db0a6e40e876657513209dfd80c75f886 Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Tue, 26 Mar 2024 11:40:32 +0200 Subject: [PATCH 18/25] Fix dev-mode issue with generated classes for Quarkus REST converters Fixes: #39598 --- .../reactive/server/deployment/ResteasyReactiveProcessor.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java index 30746797b0c0cf..49026dc2197d4e 100644 --- a/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java +++ b/extensions/resteasy-reactive/rest/deployment/src/main/java/io/quarkus/resteasy/reactive/server/deployment/ResteasyReactiveProcessor.java @@ -500,8 +500,7 @@ public void setupEndpoints(ApplicationIndexBuildItem applicationIndexBuildItem, initConverters.getMethodParam(0)); })) .setConverterSupplierIndexerExtension(new GeneratedConverterIndexerExtension( - (name) -> new GeneratedClassGizmoAdaptor(generatedClassBuildItemBuildProducer, - applicationClassPredicate.test(name)))) + (name) -> new GeneratedClassGizmoAdaptor(generatedClassBuildItemBuildProducer, true))) .setHasRuntimeConverters(!paramConverterProviders.getParamConverterProviders().isEmpty()) .setClassLevelExceptionMappers( classLevelExceptionMappers.isPresent() ? classLevelExceptionMappers.get().getMappers() From 4a5597b7f770007c35c9c8991bb08ba4c87bf263 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Thu, 21 Mar 2024 17:17:46 +0000 Subject: [PATCH 19/25] Improve the OIDC Client Quickstart document --- .../security-openid-connect-client.adoc | 264 ++++++++++++++---- 1 file changed, 214 insertions(+), 50 deletions(-) diff --git a/docs/src/main/asciidoc/security-openid-connect-client.adoc b/docs/src/main/asciidoc/security-openid-connect-client.adoc index bc8197a89763bd..65949122140388 100644 --- a/docs/src/main/asciidoc/security-openid-connect-client.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-client.adoc @@ -12,9 +12,7 @@ include::_attributes.adoc[] Learn how to use OpenID Connect (OIDC) and OAuth2 clients with filters to get, refresh, and propagate access tokens in your applications. -This approach uses an OIDC token propagation Reactive filter to propagate the incoming bearer access tokens. - -For more information about `Oidc Client` and `Token Propagation` support in Quarkus, see the xref:security-openid-connect-client-reference.adoc[OpenID Connect (OIDC) and OAuth2 client and filters reference guide]. +For more information about `OIDC Client` and `Token Propagation` support in Quarkus, see the xref:security-openid-connect-client-reference.adoc[OpenID Connect (OIDC) and OAuth2 client and filters reference guide]. To protect your applications by using Bearer Token Authorization, see the xref:security-oidc-bearer-token-authentication.adoc[OpenID Connect (OIDC) Bearer token authentication] guide. @@ -27,20 +25,26 @@ include::{includes}/prerequisites.adoc[] == Architecture In this example, an application is built with two Jakarta REST resources, `FrontendResource` and `ProtectedResource`. -Here, `FrontendResource` uses one of two methods to propagate access tokens to `ProtectedResource`: +Here, `FrontendResource` uses one of three methods to propagate access tokens to `ProtectedResource`: -* It can get a token by using an OIDC token propagation Reactive filter before propagating it. -* It can use an OIDC token propagation Reactive filter to propagate the incoming access token. +* It can get a token by using an OIDC client filter before propagating it. +* It can get a token by using a programmatically created OIDC client and propagate it by passing it to a REST client method as an HTTP `Authorization` header value. +* It can use an OIDC token propagation filter to propagate the incoming access token. -`FrontendResource` has four endpoints: +`FrontendResource` has eight endpoints: * `/frontend/user-name-with-oidc-client-token` * `/frontend/admin-name-with-oidc-client-token` +* `/frontend/user-name-with-oidc-client-token-header-param` +* `/frontend/admin-name-with-oidc-client-token-header-param` +* `/frontend/user-name-with-oidc-client-token-header-param-blocking` +* `/frontend/admin-name-with-oidc-client-token-header-param-blocking` * `/frontend/user-name-with-propagated-token` * `/frontend/admin-name-with-propagated-token` -`FrontendResource` uses a REST Client with an OIDC token propagation Reactive filter to get and propagate an access token to `ProtectedResource` when either `/frontend/user-name-with-oidc-client-token` or `/frontend/admin-name-with-oidc-client-token` is called. -Also, `FrontendResource` uses a REST Client with `OpenID Connect Token Propagation Reactive Filter` to propagate the current incoming access token to `ProtectedResource` when either `/frontend/user-name-with-propagated-token` or `/frontend/admin-name-with-propagated-token` is called. +When either `/frontend/user-name-with-oidc-client-token` or `/frontend/admin-name-with-oidc-client-token` endpoint is called, `FrontendResource` uses a REST client with an OIDC client filter to get and propagate an access token to `ProtectedResource` . +When either `/frontend/user-name-with-oidc-client-token-header-param` or `/frontend/admin-name-with-oidc-client-token-header-param` endpoint is called, `FrontendResource` uses a programmatically created OIDC client to get and propagate an access token to `ProtectedResource` by passing it to a REST client method as an HTTP `Authorization` header value. +When either `/frontend/user-name-with-propagated-token` or `/frontend/admin-name-with-propagated-token` endpoint is called, `FrontendResource` uses a REST client with `OIDC Token Propagation Filter` to propagate the current incoming access token to `ProtectedResource`. `ProtectedResource` has two endpoints: @@ -68,14 +72,14 @@ Create a new project with the following command: :create-app-extensions: oidc,rest-client-oidc-filter,rest-client-oidc-token-propagation,rest include::{includes}/devtools/create-app.adoc[] -This command generates a Maven project, importing the `oidc`, `rest-client-oidc-filter`, `rest-client-oidc-token-propagation`, and `rest` extensions. +It generates a Maven project, importing the `oidc`, `rest-client-oidc-filter`, `rest-client-oidc-token-propagation`, and `rest` extensions. If you already have your Quarkus project configured, you can add these extensions to your project by running the following command in your project base directory: :add-extension-extensions: oidc,rest-client-oidc-filter,rest-client-oidc-token-propagation,rest include::{includes}/devtools/extension-add.adoc[] -This command adds the following extensions to your build file: +It adds the following extensions to your build file: [source,xml,role="primary asciidoc-tabs-target-sync-cli asciidoc-tabs-target-sync-maven"] .pom.xml @@ -151,9 +155,13 @@ public class ProtectedResource { `ProtectedResource` returns a name from both `userName()` and `adminName()` methods. The name is extracted from the current `JsonWebToken`. -Next, add two REST clients, `OidcClientRequestReactiveFilter` and `AccessTokenRequestReactiveFilter`, which `FrontendResource` uses to call `ProtectedResource`. +Next, add three REST clients: + +1. `RestClientWithOidcClientFilter`, which uses an OIDC client filter provided by the `quarkus-rest-client-oidc-filter` extension to get and propagate an access token. +2. `RestClientWithTokenHeaderParam`, which accepts a token already acquired by the programmatically created OidcClient as an HTTP `Authorization` header value. +3. `RestClientWithTokenPropagationFilter`, which uses an OIDC token propagation filter provided by the `quarkus-rest-client-oidc-token-propagation` extension to get and propagate an access token. -Add the `OidcClientRequestReactiveFilter` REST Client: +Add the `RestClientWithOidcClientFilter` REST client: [source,java] ---- @@ -166,11 +174,11 @@ import jakarta.ws.rs.Produces; import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; -import io.quarkus.oidc.client.reactive.filter.OidcClientRequestReactiveFilter; +import io.quarkus.oidc.client.filter.OidcClientFilter; import io.smallrye.mutiny.Uni; @RegisterRestClient -@RegisterProvider(OidcClientRequestReactiveFilter.class) +@OidcClientFilter <1> @Path("/") public interface RestClientWithOidcClientFilter { @@ -185,10 +193,40 @@ public interface RestClientWithOidcClientFilter { Uni getAdminName(); } ---- +<1> Register an OIDC client filter with the REST client to get and propagate the tokens. + +Add the `RestClientWithTokenHeaderParam` REST client: + +[source,java] +---- +package org.acme.security.openid.connect.client; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +import io.smallrye.mutiny.Uni; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; + +@RegisterRestClient +@Path("/") +public interface RestClientWithTokenHeaderParam { -The `RestClientWithOidcClientFilter` interface depends on `OidcClientRequestReactiveFilter` to get and propagate the tokens. + @GET + @Produces("text/plain") + @Path("userName") + Uni getUserName(@HeaderParam("Authorization") String authorization); <1> + + @GET + @Produces("text/plain") + @Path("adminName") + Uni getAdminName(@HeaderParam("Authorization") String authorization); <1> +} +---- +<1> `RestClientWithTokenHeaderParam` REST client expects that the tokens will be passed to it as HTTP `Authorization` header values. -Add the `AccessTokenRequestReactiveFilter` REST Client: +Add the `RestClientWithTokenPropagationFilter` REST client: [source,java] ---- @@ -201,11 +239,12 @@ import jakarta.ws.rs.Produces; import org.eclipse.microprofile.rest.client.annotation.RegisterProvider; import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; -import io.quarkus.oidc.token.propagation.reactive.AccessTokenRequestReactiveFilter; +import io.quarkus.oidc.token.propagation.AccessToken; + import io.smallrye.mutiny.Uni; @RegisterRestClient -@RegisterProvider(AccessTokenRequestReactiveFilter.class) +@AccessToken <1> @Path("/") public interface RestClientWithTokenPropagationFilter { @@ -220,12 +259,63 @@ public interface RestClientWithTokenPropagationFilter { Uni getAdminName(); } ---- +<1> Register an OIDC token propagation filter with the REST client to propagate the incoming already-existing tokens. + +IMPORTANT: Do not use the `RestClientWithOidcClientFilter` and `RestClientWithTokenPropagationFilter` interfaces in the same REST client because they can conflict, leading to issues. +For example, the OIDC client filter can override the token from the OIDC token propagation filter, or the propagation filter might not work correctly if it attempts to propagate a token when none is available, expecting the OIDC client filter to obtain a new token instead. + +Also, add `OidcClientCreator` to create an OIDC client programmatically at startup. `OidcClientCreator` supports `RestClientWithTokenHeaderParam` REST client calls: + +[source,java] +---- +package org.acme.security.openid.connect.client; + +import java.util.Map; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.quarkus.oidc.client.OidcClient; +import io.quarkus.oidc.client.OidcClientConfig; +import io.quarkus.oidc.client.OidcClientConfig.Grant.Type; +import io.quarkus.oidc.client.OidcClients; +import io.quarkus.runtime.StartupEvent; +import io.smallrye.mutiny.Uni; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; + +@ApplicationScoped +public class OidcClientCreator { + + @Inject + OidcClients oidcClients; <1> + @ConfigProperty(name = "quarkus.oidc.auth-server-url") + String oidcProviderAddress; + + private volatile OidcClient oidcClient; + + public void startup(@Observes StartupEvent event) { + createOidcClient().subscribe().with(client -> {oidcClient = client;}); + } -The `RestClientWithTokenPropagationFilter` interface depends on `AccessTokenRequestReactiveFilter` to propagate the incoming already-existing tokens. + public OidcClient getOidcClient() { + return oidcClient; + } -Note that both `RestClientWithOidcClientFilter` and `RestClientWithTokenPropagationFilter` interfaces are the same. -This is because combining `OidcClientRequestReactiveFilter` and `AccessTokenRequestReactiveFilter` on the same REST Client causes side effects because both filters can interfere with each other. -For example, `OidcClientRequestReactiveFilter` can override the token propagated by `AccessTokenRequestReactiveFilter`, or `AccessTokenRequestReactiveFilter` can fail if it is called when no token is available to propagate and `OidcClientRequestReactiveFilter` is expected to get a new token instead. + private Uni createOidcClient() { + OidcClientConfig cfg = new OidcClientConfig(); + cfg.setId("myclient"); + cfg.setAuthServerUrl(oidcProviderAddress); + cfg.setClientId("backend-service"); + cfg.getCredentials().setSecret("secret"); + cfg.getGrant().setType(Type.PASSWORD); + cfg.setGrantOptions(Map.of("password", + Map.of("username", "alice", "password", "alice"))); + return oidcClients.newClient(cfg); + } +} +---- +<1> `OidcClients` can be used to retrieve the already initialized, named OIDC clients and create new OIDC clients on demand. Now, finish creating the application by adding `FrontendResource`: @@ -238,6 +328,9 @@ import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; +import io.quarkus.oidc.client.Tokens; +import io.quarkus.oidc.client.runtime.TokensHelper; + import org.eclipse.microprofile.rest.client.inject.RestClient; import io.smallrye.mutiny.Uni; @@ -246,44 +339,86 @@ import io.smallrye.mutiny.Uni; public class FrontendResource { @Inject @RestClient - RestClientWithOidcClientFilter restClientWithOidcClientFilter; + RestClientWithOidcClientFilter restClientWithOidcClientFilter; <1> + + @Inject + @RestClient + RestClientWithTokenPropagationFilter restClientWithTokenPropagationFilter; <2> + @Inject + OidcClientCreator oidcClientCreator; + TokensHelper tokenHelper = new TokensHelper(); <5> @Inject @RestClient - RestClientWithTokenPropagationFilter restClientWithTokenPropagationFilter; + RestClientWithHeaderTokenParam restClientWithHeaderTokenParam; <3> @GET @Path("user-name-with-oidc-client-token") @Produces("text/plain") - public Uni getUserNameWithOidcClientToken() { + public Uni getUserNameWithOidcClientToken() { <1> return restClientWithOidcClientFilter.getUserName(); } @GET @Path("admin-name-with-oidc-client-token") @Produces("text/plain") - public Uni getAdminNameWithOidcClientToken() { - return restClientWithOidcClientFilter.getAdminName(); + public Uni getAdminNameWithOidcClientToken() { <1> + return restClientWithOidcClientFilter.getAdminName(); } @GET @Path("user-name-with-propagated-token") @Produces("text/plain") - public Uni getUserNameWithPropagatedToken() { + public Uni getUserNameWithPropagatedToken() { <2> return restClientWithTokenPropagationFilter.getUserName(); } @GET @Path("admin-name-with-propagated-token") @Produces("text/plain") - public Uni getAdminNameWithPropagatedToken() { + public Uni getAdminNameWithPropagatedToken() { <2> return restClientWithTokenPropagationFilter.getAdminName(); } + + @GET + @Path("user-name-with-oidc-client-token-header-param") + @Produces("text/plain") + public Uni getUserNameWithOidcClientTokenHeaderParam() { <3> + return tokenHelper.getTokens(oidcClientCreator.getOidcClient()).onItem() + .transformToUni(tokens -> restClientWithTokenHeaderParam.getUserName("Bearer " + tokens.getAccessToken())); + } + + @GET + @Path("admin-name-with-oidc-client-token-header-param") + @Produces("text/plain") + public Uni getAdminNameWithOidcClientTokenHeaderParam() { <3> + return tokenHelper.getTokens(oidcClientCreator.getOidcClient()).onItem() + .transformToUni(tokens -> restClientWithTokenHeaderParam.getAdminName("Bearer " + tokens.getAccessToken())); + } + + @GET + @Path("user-name-with-oidc-client-token-header-param-blocking") + @Produces("text/plain") + public String getUserNameWithOidcClientTokenHeaderParamBlocking() { <4> + Tokens tokens = tokenHelper.getTokens(oidcClientCreator.getOidcClient()).await().indefinitely(); + return restClientWithTokenHeaderParam.getUserName("Bearer " + tokens.getAccessToken()).await().indefinitely(); + } + + @GET + @Path("admin-name-with-oidc-client-token-header-param-blocking") + @Produces("text/plain") + public String getAdminNameWithOidcClientTokenHeaderParamBlocking() { <4> + Tokens tokens = tokenHelper.getTokens(oidcClientCreator.getOidcClient()).await().indefinitely(); + return restClientWithTokenHeaderParam.getAdminName("Bearer " + tokens.getAccessToken()).await().indefinitely(); + } + } ---- - -`FrontendResource` uses REST Client with an OIDC token propagation Reactive filter to get and propagate an access token to `ProtectedResource` when either `/frontend/user-name-with-oidc-client-token` or `/frontend/admin-name-with-oidc-client-token` is called. -Also, `FrontendResource` uses REST Client with `OpenID Connect Token Propagation Reactive Filter` to propagate the current incoming access token to `ProtectedResource` when either `/frontend/user-name-with-propagated-token` or `/frontend/admin-name-with-propagated-token` is called. +<1> `FrontendResource` uses the injected `RestClientWithOidcClientFilter` REST client with the OIDC client filter to get and propagate an access token to `ProtectedResource` when either `/frontend/user-name-with-oidc-client-token` or `/frontend/admin-name-with-oidc-client-token` is called. +<2> `FrontendResource` uses the injected `RestClientWithTokenPropagationFilter` REST client with the OIDC token propagation filter to propagate the current incoming access token to `ProtectedResource` when either `/frontend/user-name-with-propagated-token` or `/frontend/admin-name-with-propagated-token` is called. +<3> `FrontendResource` uses the programmatically created OIDC client to get and propagate an access token to `ProtectedResource` by passing it directly to the injected `RestClientWithHeaderTokenParam` REST client's method as an HTTP `Authorization` header value, when either `/frontend/user-name-with-oidc-client-token-header-param` or `/frontend/admin-name-with-oidc-client-token-header-param` is called. +<4> Sometimes, one may have to acquire tokens in a blocking manner before propagating them with the REST client. This example shows how to acquire the tokens in such cases. +<5> `io.quarkus.oidc.client.runtime.TokensHelper` is a useful tool when OIDC client is used directly, without the OIDC client filter. To use `TokensHelper`, pass OIDC Client to it to get the tokens and `TokensHelper` acquires the tokens and refreshes them if necessary in a thread-safe way. Finally, add a Jakarta REST `ExceptionMapper`: @@ -309,7 +444,7 @@ public class FrontendExceptionMapper implements ExceptionMapper Date: Tue, 26 Mar 2024 16:45:47 +0200 Subject: [PATCH 20/25] Bring back erroneously removed @BuildStep Fixes: #39699 --- .../quarkus/deployment/pkg/steps/UpxCompressionBuildStep.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/UpxCompressionBuildStep.java b/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/UpxCompressionBuildStep.java index 237e18cafa3445..18807695b95290 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/UpxCompressionBuildStep.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/pkg/steps/UpxCompressionBuildStep.java @@ -16,6 +16,7 @@ import org.jboss.logging.Logger; import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.pkg.NativeConfig; import io.quarkus.deployment.pkg.builditem.ArtifactResultBuildItem; import io.quarkus.deployment.pkg.builditem.NativeImageBuildItem; @@ -34,6 +35,7 @@ public class UpxCompressionBuildStep { */ private static final String PATH = "PATH"; + @BuildStep(onlyIf = NativeBuild.class) public void compress(NativeConfig nativeConfig, NativeImageRunnerBuildItem nativeImageRunner, NativeImageBuildItem image, BuildProducer upxCompressedProducer, From a05495afda673014b609a13a5b92f48a54a5c0b8 Mon Sep 17 00:00:00 2001 From: Andy Damevin Date: Tue, 26 Mar 2024 16:08:14 +0100 Subject: [PATCH 21/25] Use --no-daemon when calling gradle update --- .../cli/src/main/java/io/quarkus/cli/build/GradleRunner.java | 1 + 1 file changed, 1 insertion(+) diff --git a/devtools/cli/src/main/java/io/quarkus/cli/build/GradleRunner.java b/devtools/cli/src/main/java/io/quarkus/cli/build/GradleRunner.java index 4267b079b59420..eab5511cb5170c 100644 --- a/devtools/cli/src/main/java/io/quarkus/cli/build/GradleRunner.java +++ b/devtools/cli/src/main/java/io/quarkus/cli/build/GradleRunner.java @@ -156,6 +156,7 @@ public Integer updateProject(TargetQuarkusVersionGroup targetQuarkusVersion, Rew args.add("-PquarkusPluginVersion=" + ToolsUtils.getGradlePluginVersion(props)); args.add("--console"); args.add("plain"); + args.add("--no-daemon"); args.add("--stacktrace"); args.add("quarkusUpdate"); if (!StringUtil.isNullOrEmpty(targetQuarkusVersion.platformVersion)) { From 7a208f4d1e948e8d6f07e7c3c684a0671b304ff4 Mon Sep 17 00:00:00 2001 From: Bas Passon Date: Mon, 25 Mar 2024 22:25:28 +0100 Subject: [PATCH 22/25] Fixes #25682 postgres devservice not working with rancher-desktop on mac arm architecture --- .../deployment/PostgresqlDevServicesProcessor.java | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/extensions/devservices/postgresql/src/main/java/io/quarkus/devservices/postgresql/deployment/PostgresqlDevServicesProcessor.java b/extensions/devservices/postgresql/src/main/java/io/quarkus/devservices/postgresql/deployment/PostgresqlDevServicesProcessor.java index abdd0425a0de49..af09e7e9fc499f 100644 --- a/extensions/devservices/postgresql/src/main/java/io/quarkus/devservices/postgresql/deployment/PostgresqlDevServicesProcessor.java +++ b/extensions/devservices/postgresql/src/main/java/io/quarkus/devservices/postgresql/deployment/PostgresqlDevServicesProcessor.java @@ -12,7 +12,8 @@ import org.jboss.logging.Logger; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.containers.wait.strategy.LogMessageWaitStrategy; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.containers.wait.strategy.WaitAllStrategy; import org.testcontainers.utility.DockerImageName; import io.quarkus.datasource.common.runtime.DataSourceUtil; @@ -99,16 +100,21 @@ public QuarkusPostgreSQLContainer(Optional imageName, OptionalInt fixedE super(DockerImageName .parse(imageName.orElseGet(() -> ConfigureUtil.getDefaultImageNameFor("postgresql"))) .asCompatibleSubstituteFor(DockerImageName.parse(PostgreSQLContainer.IMAGE))); + this.fixedExposedPort = fixedExposedPort; this.useSharedNetwork = useSharedNetwork; + // Workaround for https://github.com/testcontainers/testcontainers-java/issues/4799. // The motivation of this custom wait strategy is that Testcontainers fails to start a Postgresql database when it // has been already initialized. // This custom wait strategy will work fine regardless of the state of the Postgresql database. // More information in the issue ticket in Testcontainers. - this.waitStrategy = new LogMessageWaitStrategy() - .withRegEx("(" + READY_REGEX + ")?(" + SKIPPING_INITIALIZATION_REGEX + ")?") - .withTimes(2) + + // Added Wait.forListeningPort() for https://github.com/quarkusio/quarkus/issues/25682 + // as suggested by https://github.com/testcontainers/testcontainers-java/pull/6309 + this.waitStrategy = new WaitAllStrategy() + .withStrategy(Wait.forLogMessage("(" + READY_REGEX + ")?(" + SKIPPING_INITIALIZATION_REGEX + ")?", 2)) + .withStrategy(Wait.forListeningPort()) .withStartupTimeout(Duration.of(60L, ChronoUnit.SECONDS)); } From 557e40e79f47d20ac84810b58d5ce38ac2d4fed9 Mon Sep 17 00:00:00 2001 From: "David M. Lloyd" Date: Tue, 26 Mar 2024 11:32:35 -0500 Subject: [PATCH 23/25] Allow native profile to be overridden This functionality was mistakenly removed in #39295. Fixes #39669. --- .../java/io/quarkus/maven/QuarkusBootstrapMojo.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java b/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java index 364e455dc3e64e..d2e1457ab4cda8 100644 --- a/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java +++ b/devtools/maven/src/main/java/io/quarkus/maven/QuarkusBootstrapMojo.java @@ -304,8 +304,15 @@ protected Properties getBuildSystemProperties(boolean quarkusOnly) throws MojoEx * @return true if the package type system property was set, otherwise - false */ protected boolean setNativeEnabledIfNativeProfileEnabled() { - if (isNativeProfileEnabled(mavenProject())) { - System.setProperty("quarkus.native.enabled", "true"); + if (!System.getProperties().containsKey("quarkus.native.enabled") && isNativeProfileEnabled(mavenProject())) { + Object nativeEnabledProp = mavenProject().getProperties().get("quarkus.native.enabled"); + String nativeEnabled; + if (nativeEnabledProp != null) { + nativeEnabled = nativeEnabledProp.toString(); + } else { + nativeEnabled = "true"; + } + System.setProperty("quarkus.native.enabled", nativeEnabled); return true; } else { return false; From 5f60a6db7317c37cda66049215a5b180963d9129 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Tue, 26 Mar 2024 19:40:18 +0000 Subject: [PATCH 24/25] Enforce OIDC code flow access token verification only if JWT is in the application code --- .../oidc/deployment/OidcBuildStep.java | 11 ++++ .../CodeFlowVerifyAccessTokenDisabled.java | 54 +++++++++++++++++++ ...rotectedResourceWithoutJwtAccessToken.java | 28 ++++++++++ ...on-verify-access-token-disabled.properties | 4 ++ 4 files changed, 97 insertions(+) create mode 100644 extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowVerifyAccessTokenDisabled.java create mode 100644 extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ProtectedResourceWithoutJwtAccessToken.java create mode 100644 extensions/oidc/deployment/src/test/resources/application-verify-access-token-disabled.properties diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java index 9679f0d0b3c7ab..c3e7934b780bc0 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java @@ -86,6 +86,9 @@ public class OidcBuildStep { private static final DotName JSON_WEB_TOKEN_NAME = DotName.createSimple(JsonWebToken.class); private static final DotName ID_TOKEN_NAME = DotName.createSimple(IdToken.class); + private static final String QUARKUS_TOKEN_PROPAGATION_PACKAGE = "io.quarkus.oidc.token.propagation"; + private static final String SMALLRYE_JWT_PACKAGE = "io.smallrye.jwt"; + @BuildStep public void provideSecurityInformation(BuildProducer securityInformationProducer) { // TODO: By default quarkus.oidc.application-type = service @@ -323,13 +326,21 @@ private static boolean isInjected(BeanRegistrationPhaseBuildItem beanRegistratio DotName withoutQualifier) { for (InjectionPointInfo injectionPoint : beanRegistrationPhaseBuildItem.getInjectionPoints()) { if (requiredType.equals(injectionPoint.getRequiredType().name()) + && isApplicationPackage(injectionPoint.getTargetInfo()) && (withoutQualifier == null || injectionPoint.getRequiredQualifier(withoutQualifier) == null)) { + LOG.debugf("%s injection point: %s", requiredType.toString(), injectionPoint.getTargetInfo()); return true; } } return false; } + private static boolean isApplicationPackage(String injectionPointTargetInfo) { + return injectionPointTargetInfo != null + && !injectionPointTargetInfo.startsWith(QUARKUS_TOKEN_PROPAGATION_PACKAGE) + && !injectionPointTargetInfo.startsWith(SMALLRYE_JWT_PACKAGE); + } + private static String toTargetName(AnnotationTarget target) { if (target.kind() == CLASS) { return target.asClass().name().toString(); diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowVerifyAccessTokenDisabled.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowVerifyAccessTokenDisabled.java new file mode 100644 index 00000000000000..f4a1e67a28b44b --- /dev/null +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/CodeFlowVerifyAccessTokenDisabled.java @@ -0,0 +1,54 @@ +package io.quarkus.oidc.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; +import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.html.HtmlForm; +import com.gargoylesoftware.htmlunit.html.HtmlPage; + +import io.quarkus.test.QuarkusUnitTest; +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.keycloak.server.KeycloakTestResourceLifecycleManager; + +@QuarkusTestResource(KeycloakTestResourceLifecycleManager.class) +public class CodeFlowVerifyAccessTokenDisabled { + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .withApplicationRoot((jar) -> jar + .addClasses(ProtectedResourceWithoutJwtAccessToken.class) + .addAsResource("application-verify-access-token-disabled.properties", "application.properties")); + + @Test + public void testVerifyAccessTokenDisabled() throws IOException, InterruptedException { + try (final WebClient webClient = createWebClient()) { + + HtmlPage page = webClient.getPage("http://localhost:8081/protected"); + + 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("alice:false", page.getBody().asNormalizedText()); + + webClient.getCookieManager().clearCookies(); + } + } + + private WebClient createWebClient() { + WebClient webClient = new WebClient(); + webClient.setCssErrorHandler(new SilentCssErrorHandler()); + return webClient; + } +} diff --git a/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ProtectedResourceWithoutJwtAccessToken.java b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ProtectedResourceWithoutJwtAccessToken.java new file mode 100644 index 00000000000000..d11ae1b9fdb02a --- /dev/null +++ b/extensions/oidc/deployment/src/test/java/io/quarkus/oidc/test/ProtectedResourceWithoutJwtAccessToken.java @@ -0,0 +1,28 @@ +package io.quarkus.oidc.test; + +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.oidc.runtime.OidcConfig; +import io.quarkus.security.Authenticated; + +@Path("/protected") +@Authenticated +public class ProtectedResourceWithoutJwtAccessToken { + + @Inject + @IdToken + JsonWebToken idToken; + + @Inject + OidcConfig config; + + @GET + public String getName() { + return idToken.getName() + ":" + config.defaultTenant.authentication.verifyAccessToken; + } +} diff --git a/extensions/oidc/deployment/src/test/resources/application-verify-access-token-disabled.properties b/extensions/oidc/deployment/src/test/resources/application-verify-access-token-disabled.properties new file mode 100644 index 00000000000000..f01af5aad3860a --- /dev/null +++ b/extensions/oidc/deployment/src/test/resources/application-verify-access-token-disabled.properties @@ -0,0 +1,4 @@ +quarkus.oidc.auth-server-url=${keycloak.url}/realms/quarkus +quarkus.oidc.client-id=quarkus-web-app +quarkus.oidc.credentials.secret=secret +quarkus.oidc.application-type=web-app From 627f7abf9efb2821d31be1c698083f5c463fab33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Mar 2024 23:21:04 +0000 Subject: [PATCH 25/25] Bump org.mvnpm.at.mvnpm:vaadin-webcomponents from 24.3.8 to 24.3.10 Bumps [org.mvnpm.at.mvnpm:vaadin-webcomponents](https://github.com/vaadin/web-components) from 24.3.8 to 24.3.10. - [Release notes](https://github.com/vaadin/web-components/releases) - [Commits](https://github.com/vaadin/web-components/compare/v24.3.8...v24.3.10) --- updated-dependencies: - dependency-name: org.mvnpm.at.mvnpm:vaadin-webcomponents dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- bom/dev-ui/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bom/dev-ui/pom.xml b/bom/dev-ui/pom.xml index bab44f4111ee92..c461772d70c91f 100644 --- a/bom/dev-ui/pom.xml +++ b/bom/dev-ui/pom.xml @@ -13,7 +13,7 @@ Dependency management for dev-ui. Importable by third party extension developers. - 24.3.8 + 24.3.10 3.1.2 4.0.4 3.1.2