Skip to content

Commit

Permalink
Merge pull request #34812 from sberyozkin/oidc_javascript_checker
Browse files Browse the repository at this point in the history
Allow to customize OIDC JavaRequest checks
  • Loading branch information
sberyozkin committed Jul 18, 2023
2 parents 51dbd47 + 9ef513e commit 52f7611
Show file tree
Hide file tree
Showing 9 changed files with 124 additions and 5 deletions.
29 changes: 27 additions & 2 deletions docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
@@ -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).
* <p/>
* 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.
* <p/>
* 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
* <p/>
* 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +42,9 @@ public class DefaultTenantConfigResolver {
@Inject
Instance<TenantConfigResolver> tenantConfigResolver;

@Inject
Instance<JavaScriptRequestChecker> javaScriptRequestChecker;

@Inject
TenantConfigBean tenantConfigBean;

Expand Down Expand Up @@ -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<OidcTenantConfig> resolveConfig(RoutingContext context) {
Expand Down Expand Up @@ -240,6 +247,10 @@ public TenantConfigBean getTenantConfigBean() {
return tenantConfigBean;
}

public JavaScriptRequestChecker getJavaScriptRequestChecker() {
return javaScriptRequestChecker.isResolvable() ? javaScriptRequestChecker.get() : null;
}

private class DefaultStaticTenantResolver implements TenantResolver {

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@

public class TokenCustomizerFinder {

private TokenCustomizerFinder() {

}

public static TokenCustomizer find(OidcTenantConfig oidcConfig) {
if (oidcConfig == null) {
return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down

0 comments on commit 52f7611

Please sign in to comment.