From 9ef513e20df69516a061c175eff37ab006478933 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Mon, 17 Jul 2023 19:11:29 +0100 Subject: [PATCH] Allow to customize OIDC JavaRequest checks --- ...ecurity-oidc-code-flow-authentication.adoc | 29 +++++++++++++++++-- .../oidc/JavaScriptRequestChecker.java | 25 ++++++++++++++++ .../io/quarkus/oidc/OidcTenantConfig.java | 11 +++++-- .../runtime/CodeAuthenticationMechanism.java | 5 ++++ .../runtime/DefaultTenantConfigResolver.java | 11 +++++++ .../oidc/runtime/TokenCustomizerFinder.java | 4 +++ .../CustomJavaScriptRequestChecker.java | 16 ++++++++++ .../src/main/resources/application.properties | 10 +++++++ .../BearerTokenAuthorizationTest.java | 18 ++++++++++++ 9 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/JavaScriptRequestChecker.java create mode 100644 integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomJavaScriptRequestChecker.java diff --git a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc index 9a5c6a8f48d89..02ae3fb7860e6 100644 --- a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc @@ -1072,8 +1072,33 @@ You can check if implementing single-page applications (SPAs) the way it is sugg If you prefer to use SPAs and JavaScript APIs such as `Fetch` or `XMLHttpRequest`(XHR) with Quarkus web applications, be aware that OpenID Connect providers might not support cross-origin resource sharing (CORS) for authorization endpoints where the users are authenticated after a redirect from Quarkus. This will lead to authentication failures if the Quarkus application and the OpenID Connect provider are hosted on different HTTP domains, ports, or both. -In such cases, set the `quarkus.oidc.authentication.java-script-auto-redirect` property to `false`, which will instruct Quarkus to return a `499` status code and a `WWW-Authenticate` header with the `OIDC` value. -You must also update the browser script to set the `X-Requested-With` header with the `JavaScript` value and reload the last requested page in case of a `499` status code. +In such cases, set the `quarkus.oidc.authentication.java-script-auto-redirect` property to `false`, which will instruct Quarkus to return a `499` status code and a `WWW-Authenticate` header with the `OIDC` value. + +The browser script must set a header to identify the current request as a JavaScript request for `499` status code to be returned when `quarkus.oidc.authentication.java-script-auto-redirect` property is set to `false`. + +If the script engine sets an engine-specific request header itself, then you can register a custom `quarkus.oidc.JavaScriptRequestChecker` bean, which will inform Quarkus if the current request is a JavaScript request. For example, if the JavaScript engine sets a header such as `HX-Request: true` then you can have it checked like this: + +[source,java] +---- +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.oidc.JavaScriptRequestChecker; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class CustomJavaScriptRequestChecker implements JavaScriptRequestChecker { + + @Override + public boolean isJavaScriptRequest(RoutingContext context) { + return "true".equals(context.request().getHeader("HX-Request")); + } +} +---- + +and reload the last requested page in case of a `499` status code. + +Otherwise you must also update the browser script to set the `X-Requested-With` header with the `JavaScript` value and reload the last requested page in case of a `499` status code. + For example: [source,javascript] diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/JavaScriptRequestChecker.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/JavaScriptRequestChecker.java new file mode 100644 index 0000000000000..81e08b2dd173b --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/JavaScriptRequestChecker.java @@ -0,0 +1,25 @@ +package io.quarkus.oidc; + +import io.vertx.ext.web.RoutingContext; + +/** + * JavaScriptRequestChecker can be used to check if the current request was made + * by JavaScript running inside Single-page application (SPA). + *

+ * Some OpenId Connect providers may not support CORS in their authorization endpoints. + * In such cases, SPA needs to avoid using JavaScript for running authorization code flow redirects + * and instead delegate it to the browser. + *

+ * If this checker confirms it is a JavaScript request and if authentication challenge redirects are also disabled with + * 'quarkus.oidc.authentication.java-script-auto-redirect=false' then an HTTP error status `499` will be reported allowing + * SPA to intercept this error and repeat the last request causing the challenge with the browser API. + */ +public interface JavaScriptRequestChecker { + /** + * Check if the current request was made by JavaScript + * + * @param context {@link RoutingContext} + * @return true if the current request was made by JavaScript + */ + boolean isJavaScriptRequest(RoutingContext context); +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java index b9340ab472239..64aedc8b5bcf3 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java @@ -907,11 +907,16 @@ public enum ResponseMode { /** * If this property is set to 'true' then a normal 302 redirect response will be returned * if the request was initiated via JavaScript API such as XMLHttpRequest or Fetch and the current user needs to be - * (re)authenticated which may not be desirable for Single Page Applications since + * (re)authenticated which may not be desirable for Single-page applications (SPA) since * it automatically following the redirect may not work given that OIDC authorization endpoints typically do not support * CORS. - * If this property is set to `false` then a status code of '499' will be returned to allow - * the client to handle the redirect manually + *

+ * If this property is set to 'false' then a status code of '499' will be returned to allow + * SPA to handle the redirect manually if a request header identifying current request as a JavaScript request is found. + * 'X-Requested-With' request header with its value set to either `JavaScript` or `XMLHttpRequest` is expected by + * default if + * this property is enabled. You can register a custom {@linkplain JavaScriptRequestChecker} to do a custom JavaScript + * request check instead. */ @ConfigItem(defaultValue = "true") public boolean javaScriptAutoRedirect = true; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java index b3c38295b1b5c..3cb1361e3d4db 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java @@ -29,6 +29,7 @@ import io.quarkus.logging.Log; import io.quarkus.oidc.AuthorizationCodeTokens; import io.quarkus.oidc.IdTokenCredential; +import io.quarkus.oidc.JavaScriptRequestChecker; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.OidcTenantConfig.Authentication; import io.quarkus.oidc.OidcTenantConfig.Authentication.ResponseMode; @@ -511,6 +512,10 @@ private boolean isIdTokenRequired(TenantConfigContext configContext) { } private boolean isJavaScript(RoutingContext context) { + JavaScriptRequestChecker checker = resolver.getJavaScriptRequestChecker(); + if (checker != null) { + return checker.isJavaScriptRequest(context); + } String value = context.request().getHeader("X-Requested-With"); return "JavaScript".equals(value) || "XMLHttpRequest".equals(value); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java index 270d9058d7821..39331278889a1 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java @@ -13,6 +13,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; +import io.quarkus.oidc.JavaScriptRequestChecker; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.SecurityEvent; @@ -41,6 +42,9 @@ public class DefaultTenantConfigResolver { @Inject Instance tenantConfigResolver; + @Inject + Instance javaScriptRequestChecker; + @Inject TenantConfigBean tenantConfigBean; @@ -83,6 +87,9 @@ public void verifyResolvers() { if (userInfoCache.isAmbiguous()) { throw new IllegalStateException("Multiple " + UserInfo.class + " beans registered"); } + if (javaScriptRequestChecker.isAmbiguous()) { + throw new IllegalStateException("Multiple " + JavaScriptRequestChecker.class + " beans registered"); + } } Uni resolveConfig(RoutingContext context) { @@ -240,6 +247,10 @@ public TenantConfigBean getTenantConfigBean() { return tenantConfigBean; } + public JavaScriptRequestChecker getJavaScriptRequestChecker() { + return javaScriptRequestChecker.isResolvable() ? javaScriptRequestChecker.get() : null; + } + private class DefaultStaticTenantResolver implements TenantResolver { @Override diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java index 24e9ddfbd171c..20b58d434e4c4 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TokenCustomizerFinder.java @@ -10,6 +10,10 @@ public class TokenCustomizerFinder { + private TokenCustomizerFinder() { + + } + public static TokenCustomizer find(OidcTenantConfig oidcConfig) { if (oidcConfig == null) { return null; diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomJavaScriptRequestChecker.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomJavaScriptRequestChecker.java new file mode 100644 index 0000000000000..9c9117ba333d8 --- /dev/null +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomJavaScriptRequestChecker.java @@ -0,0 +1,16 @@ +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.oidc.JavaScriptRequestChecker; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class CustomJavaScriptRequestChecker implements JavaScriptRequestChecker { + + @Override + public boolean isJavaScriptRequest(RoutingContext context) { + return "true".equals(context.request().getHeader("HX-Request")); + } + +} diff --git a/integration-tests/oidc-tenancy/src/main/resources/application.properties b/integration-tests/oidc-tenancy/src/main/resources/application.properties index 8337049ec45c6..3f386b7933edb 100644 --- a/integration-tests/oidc-tenancy/src/main/resources/application.properties +++ b/integration-tests/oidc-tenancy/src/main/resources/application.properties @@ -51,6 +51,16 @@ quarkus.oidc.tenant-web-app.credentials.secret=secret quarkus.oidc.tenant-web-app.application-type=web-app quarkus.oidc.tenant-web-app.roles.source=userinfo quarkus.oidc.tenant-web-app.allow-user-info-cache=false +# Adding this property should not affect the flow if no expected request header +# "HX-Request" identifiying it as a JavaScript request is found +quarkus.oidc.tenant-web-app.authentication.java-script-auto-redirect=false + +# Tenant Web App Java Script +quarkus.oidc.tenant-web-app-javascript.auth-server-url=${keycloak.url}/realms/quarkus-webapp +quarkus.oidc.tenant-web-app-javascript.client-id=quarkus-app-webapp +quarkus.oidc.tenant-web-app-javascript.credentials.secret=secret +quarkus.oidc.tenant-web-app-javascript.authentication.java-script-auto-redirect=false +quarkus.oidc.tenant-web-app-javascript.application-type=web-app # Tenant Web App No Discovery (Introspection + User Info) quarkus.oidc.tenant-web-app-no-discovery.auth-server-url=${keycloak.url}/realms/quarkus-webapp diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index 12c500bd7bb76..ada7f13607413 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -6,6 +6,7 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.fail; import java.io.IOException; import java.net.URI; @@ -16,6 +17,7 @@ import org.junit.jupiter.api.Test; +import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException; import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; import com.gargoylesoftware.htmlunit.WebClient; import com.gargoylesoftware.htmlunit.WebRequest; @@ -86,6 +88,22 @@ public void testResolveTenantIdentifierWebApp() throws IOException { } } + @Test + public void testJavaScriptRequest() throws IOException, InterruptedException { + try (final WebClient webClient = createWebClient()) { + try { + webClient.addRequestHeader("HX-Request", "true"); + webClient.getPage("http://localhost:8081/tenant/tenant-web-app-javascript/api/user/webapp"); + fail("499 status error is expected"); + } catch (FailingHttpStatusCodeException ex) { + assertEquals(499, ex.getStatusCode()); + assertEquals("OIDC", ex.getResponse().getResponseHeaderValue("WWW-Authenticate")); + } + + webClient.getCookieManager().clearCookies(); + } + } + @Test public void testResolveTenantIdentifierWebApp2() throws IOException { try (final WebClient webClient = createWebClient()) {