From 5028da3dd15e869ad75f3d91d738e0569e77e216 Mon Sep 17 00:00:00 2001 From: Sergey Beryozkin Date: Mon, 13 May 2024 16:35:09 +0100 Subject: [PATCH] Introduce OidcRedirectFilter --- ...ecurity-oidc-code-flow-authentication.adoc | 129 ++++++++++++++++++ .../io/quarkus/oidc/OidcRedirectFilter.java | 31 +++++ .../runtime/CodeAuthenticationMechanism.java | 48 ++++--- .../io/quarkus/oidc/runtime/OidcUtils.java | 19 ++- .../oidc/runtime/TenantConfigContext.java | 9 ++ .../it/keycloak/CustomTenantResolver.java | 2 +- .../it/keycloak/GlobalOidcRedirectFilter.java | 19 +++ .../SessionExpiredOidcRedirectFilter.java | 38 ++++++ .../io/quarkus/it/keycloak/TenantRefresh.java | 33 +++++ .../src/main/resources/application.properties | 6 +- .../io/quarkus/it/keycloak/CodeFlowTest.java | 14 +- .../KeycloakRealmResourceManager.java | 12 +- 12 files changed, 331 insertions(+), 29 deletions(-) create mode 100644 extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcRedirectFilter.java create mode 100644 integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/GlobalOidcRedirectFilter.java create mode 100644 integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/SessionExpiredOidcRedirectFilter.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 e35ca4f0aade57..b59fb25bd18349 100644 --- a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc @@ -385,6 +385,7 @@ For example, `quarkus.oidc.authentication.redirect-path=/service/callback`, and If `quarkus.oidc.authentication.redirect-path` is set, but you need the original request URL to be restored after the user is redirected back to a unique callback URL, for example, `http://localhost:8080/service/callback`, set `quarkus.oidc.authentication.restore-path-after-redirect` property to `true`. This will restore the request URL such as `http://localhost:8080/service/1`. +[[customize-authentication-requests]] ==== Customizing authentication requests By default, only the `response_type` (set to `code`), `scope` (set to `openid`), `client_id`, `redirect_uri`, and `state` properties are passed as HTTP query parameters to the OIDC provider's authorization endpoint when the user is redirected to it to authenticate. @@ -398,6 +399,8 @@ The following example shows how you can work around this issue: quarkus.oidc.authentication.extra-params.response_mode=query ---- +See also the <> section explaining how a custom `OidcRedirectFilter` can be used to customize OIDC redirects, including those to the OIDC authorization endpoint. + ==== Customizing the authentication error response When the user is redirected to the OIDC authorization endpoint to authenticate and, if necessary, authorize the Quarkus application, this redirect request might fail, for example, when an invalid scope is included in the redirect URI. @@ -422,6 +425,130 @@ For example, if it is set to '/error' and the current request URI is `https://lo To prevent the user from being redirected to this page to be re-authenticated, ensure that this error endpoint is a public resource. ==== +[[oidc-redirect-filters]] +=== OIDC redirect filters + +You can register one or more `io.quarkus.oidc.OidcRedirectFilter` implementations to filter OIDC redirects to OIDC authorization and logout endpoints but also local redirects to custom error and session expired pages. Custom `OidcRedirectFilter` can add additional query parameters, response headers and set new cookies. + +For example, the following simple custom `OidcRedirectFilter` adds an additional query parameter and a custom response header for all redirect requests that can be done by Quarkus OIDC: + +[source,java] +---- +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.OidcRedirectFilter; + +@ApplicationScoped +@Unremovable +public class GlobalOidcRedirectFilter implements OidcRedirectFilter { + + @Override + public void filter(OidcRedirectContext context) { + if (context.redirectUri().contains("/session-expired-page")) { + context.additionalQueryParams().add("redirect-filtered", "true,"); <1> + context.routingContext().response().putHeader("Redirect-Filtered", "true"); <2> + } + } + +} +---- +<1> Add an additional query parameter. Note the queury names and values are URL-encoded by Quarkus OIDC, a `redirect-filtered=true%20C` query parameter is added to the redirect URI in this case. +<2> Add a custom HTTP response header. + +See also the <> section how to configure additional query parameters for OIDC authorization point. + +Custom `OidcRedirectFilter` for local error and session expired pages can also create secure cookies to help with generating such pages. + +For example, let's assume you need to redirect the current user whose session has expired to a custom session expired page available at `http://localhost:8080/session-expired-page`. The following custom `OidcRedirectFilter` encrypts the user name in a custom `session_expired` cookie using an OIDC tenant client secret: + +[source,java] +---- +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.jwt.Claims; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.AuthorizationCodeTokens; +import io.quarkus.oidc.OidcRedirectFilter; +import io.quarkus.oidc.TenantFeature; +import io.quarkus.oidc.runtime.OidcUtils; +import io.smallrye.jwt.build.Jwt; + +@ApplicationScoped +@Unremovable +@TenantFeature("tenant-refresh") +public class SessionExpiredOidcRedirectFilter implements OidcRedirectFilter { + + @Override + public void filter(OidcRedirectContext context) { + + if (context.redirectUri().contains("/session-expired-page")) { + AuthorizationCodeTokens tokens = context.routingContext().get(AuthorizationCodeTokens.class.getName()); <1> + String userName = OidcUtils.decodeJwtContent(tokens.getIdToken()).getString(Claims.preferred_username.name()); <2> + String jwe = Jwt.preferredUserName(userName).jwe() + .encryptWithSecret(context.oidcTenantConfig().credentials.secret.get()); <3> + OidcUtils.createCookie(context.routingContext(), context.oidcTenantConfig(), "session_expired", + jwe + "|" + context.oidcTenantConfig().tenantId.get(), 10); <4> + } + } +} + +---- +<1> Access `AuthorizationCodeTokens` tokens associated with the now expired session as a `RoutingContext` attribute. +<2> Decode ID token claims and get a user name. +<3> Save the user name in a JWT token encrypted with the current OIDC tenant's client secret. +<4> Create a custom `session_expired` cookie valid for 5 seconds which joins the encrypted token and a tenant id using a "|" separator. Recording a tenant id in a custom cookie can help to generate correct session expired pages in a multi-tenant OIDC setup. + +Next, a public JAX-RS resource which generates session expired pages can use this cookie to create a page tailored for this user and the corresponding OIDC tenant, for example: + +[source,java] +---- +package io.quarkus.it.keycloak; + +import jakarta.inject.Inject; +import jakarta.ws.rs.CookieParam; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +import org.eclipse.microprofile.jwt.Claims; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.runtime.TenantConfigBean; +import io.smallrye.jwt.auth.principal.DefaultJWTParser; +import io.vertx.ext.web.RoutingContext; + +@Path("/session-expired-page") +public class SessionExpiredResource { + + @Inject + TenantConfigBean tenantConfig; <1> + + @GET + public String sessionExpired(@CookieParam("session_expired") String sessionExpired) throws Exception { + // Cookie format: jwt| + + String[] pair = sessionExpired.split("\\|"); <2> + OidcTenantConfig oidcConfig = tenantConfig.getStaticTenantsConfig().get(pair[1]).getOidcTenantConfig(); <3> + JsonWebToken jwt = new DefaultJWTParser().decrypt(pair[0], oidcConfig.credentials.secret.get()); <4> + OidcUtils.removeCookie(context, oidcConfig, "session_expired"); <5> + return jwt.getClaim(Claims.preferred_username) + ", your session has expired. " + + "Please login again at http://localhost:8081/" + oidcConfig.tenantId.get(); <6> + } +} +---- +<1> Inject `TenantConfigBean` which can be used to access all the current OIDC tenant configurations. +<2> Split the custom cookie value into 2 parts, first part is the encrypted token, last part is the tenant id. +<3> Get the OIDC tenant configuration. +<4> Decrypt the cookie value using the OIDC tenant's client secret. +<5> Remove the custom cookie. +<6> Use the username in the decrypted token and the tenant id to generate the service expired page response. + === Accessing authorization data You can access information about authorization in different ways. @@ -1110,6 +1237,8 @@ When the session can not be refreshed, the currently authenticated user is redir Instead, you can request that the user is redirected to a public, application specific session expired page first. This page informs the user that the session has now expired and advise to re-authenticate by following a link to a secured application welcome page. The user clicks on the link and Quarkus OIDC enforces a redirect to the OIDC provider to re-authenticate. Use `quarkus.oidc.authentication.session-expired-page` relative path property, if you'd like to do it. For example, setting `quarkus.oidc.authentication.session-expired-page=/session-expired-page` will ensure that the user whose session has expired is redirected to `http://localhost:8080/session-expired-page`, assuming the application is available at `http://localhost:8080`. + +See also the <> section explaining how a custom `OidcRedirectFilter` can be used to customize OIDC redirects, including those to the session expired pages. ==== diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcRedirectFilter.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcRedirectFilter.java new file mode 100644 index 00000000000000..46f11c6f7e7a6b --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcRedirectFilter.java @@ -0,0 +1,31 @@ +package io.quarkus.oidc; + +import io.vertx.core.MultiMap; +import io.vertx.ext.web.RoutingContext; + +/** + * OIDC redirect filter which can be used to customize redirect requests to OIDC authorization and logout endpoints + * as well as local redirects to OIDC tenant error, session expired and other pages. + */ +public interface OidcRedirectFilter { + + /** + * OIDC redirect context which provides access to the routing context, current OIDC tenant configuration, redirect uri + * and additional query parameters. + * The additional query parameters are visible to all OIDC redirect filters. They are URL-encoded and added to + * the redirect URI after all the filters have run. + */ + record OidcRedirectContext(RoutingContext routingContext, OidcTenantConfig oidcTenantConfig, + String redirectUri, MultiMap additionalQueryParams) { + } + + /** + * Filter OIDC redirect. + * + * @param redirectContext the redirect context which provides access to the routing context, current OIDC tenant + * configurationthe redirect uri which filters may update by adding additional query parameters, + * redirect URI and additional query parameters. + * + */ + void filter(OidcRedirectContext redirectContext); +} 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 f6cf3d717aa11c..a25c5eebd29a9c 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 @@ -28,6 +28,8 @@ import io.quarkus.oidc.AuthorizationCodeTokens; import io.quarkus.oidc.IdTokenCredential; import io.quarkus.oidc.JavaScriptRequestChecker; +import io.quarkus.oidc.OidcRedirectFilter; +import io.quarkus.oidc.OidcRedirectFilter.OidcRedirectContext; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.OidcTenantConfig.Authentication; import io.quarkus.oidc.OidcTenantConfig.Authentication.ResponseMode; @@ -52,7 +54,6 @@ import io.vertx.core.http.Cookie; import io.vertx.core.http.CookieSameSite; import io.vertx.core.http.HttpHeaders; -import io.vertx.core.http.impl.CookieImpl; import io.vertx.core.http.impl.ServerCookie; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.RoutingContext; @@ -61,6 +62,7 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha public static final String SESSION_MAX_AGE_PARAM = "session-max-age"; static final String AMP = "&"; + static final String QUESTION_MARK = "?"; static final String EQ = "="; static final String COOKIE_DELIM = "|"; static final Pattern COOKIE_PATTERN = Pattern.compile("\\" + COOKIE_DELIM); @@ -227,8 +229,10 @@ public Uni apply(TenantConfigContext tenantContext) { String finalErrorUri = errorUri.toString(); LOG.debugf("Error URI: %s", finalErrorUri); - return Uni.createFrom().failure(new AuthenticationRedirectException(finalErrorUri)); + return Uni.createFrom().failure(new AuthenticationRedirectException( + filterRedirect(context, tenantContext, finalErrorUri))); } + }); } else { LOG.error( @@ -242,6 +246,24 @@ public Uni apply(TenantConfigContext tenantContext) { } + private static String filterRedirect(RoutingContext context, + TenantConfigContext tenantContext, String redirectUri) { + if (!tenantContext.getOidcRedirectFilters().isEmpty()) { + OidcRedirectContext redirectContext = new OidcRedirectContext(context, tenantContext.getOidcTenantConfig(), + redirectUri, MultiMap.caseInsensitiveMultiMap()); + for (OidcRedirectFilter filter : tenantContext.getOidcRedirectFilters()) { + filter.filter(redirectContext); + } + MultiMap queries = redirectContext.additionalQueryParams(); + if (!queries.isEmpty()) { + String encoded = OidcCommonUtils.encodeForm(new io.vertx.mutiny.core.MultiMap(queries)).toString(); + String sep = redirectUri.lastIndexOf("?") > 0 ? AMP : QUESTION_MARK; + redirectUri += (sep + encoded); + } + } + return redirectUri; + } + private Uni stateParamIsMissing(OidcTenantConfig oidcTenantConfig, RoutingContext context, Map cookies, boolean multipleStateQueryParams) { if (multipleStateQueryParams) { @@ -432,7 +454,8 @@ private Uni redirectToSessionExpiredPage(RoutingContext contex String sessionExpiredUri = sessionExpired.toString(); LOG.debugf("Session Expired URI: %s", sessionExpiredUri); return removeSessionCookie(context, configContext.oidcConfig) - .chain(() -> Uni.createFrom().failure(new AuthenticationRedirectException(sessionExpiredUri))); + .chain(() -> Uni.createFrom().failure(new AuthenticationRedirectException( + filterRedirect(context, configContext, sessionExpiredUri)))); } private static String decryptIdTokenIfEncryptedByProvider(TenantConfigContext resolvedContext, String token) { @@ -692,6 +715,7 @@ && isRedirectFromProvider(context, configContext)) { String authorizationURL = configContext.provider.getMetadata().getAuthorizationUri() + "?" + codeFlowParams.toString(); + authorizationURL = filterRedirect(context, configContext, authorizationURL); LOG.debugf("Code flow redirect to: %s", authorizationURL); return Uni.createFrom().item(new ChallengeData(HttpResponseStatus.FOUND.code(), HttpHeaders.LOCATION, @@ -848,7 +872,8 @@ public SecurityIdentity apply(SecurityIdentity identity) { String finalRedirectUri = finalUriWithoutQuery.toString(); LOG.debugf("Removing code flow redirect parameters, final redirect URI: %s", finalRedirectUri); - throw new AuthenticationRedirectException(finalRedirectUri); + throw new AuthenticationRedirectException( + filterRedirect(context, configContext, finalRedirectUri)); } else { return identity; } @@ -1151,18 +1176,9 @@ static ServerCookie createCookie(RoutingContext context, OidcTenantConfig oidcCo static ServerCookie createCookie(RoutingContext context, OidcTenantConfig oidcConfig, String name, String value, long maxAge, boolean sessionCookie) { - ServerCookie cookie = new CookieImpl(name, value); - cookie.setHttpOnly(true); - cookie.setSecure(oidcConfig.authentication.cookieForceSecure || context.request().isSSL()); - cookie.setMaxAge(maxAge); - LOG.debugf(name + " cookie 'max-age' parameter is set to %d", maxAge); - Authentication auth = oidcConfig.getAuthentication(); - OidcUtils.setCookiePath(context, auth, cookie); - if (auth.cookieDomain.isPresent()) { - cookie.setDomain(auth.getCookieDomain().get()); - } + ServerCookie cookie = OidcUtils.createCookie(context, oidcConfig, name, value, maxAge); if (sessionCookie) { - cookie.setSameSite(CookieSameSite.valueOf(auth.cookieSameSite.name())); + cookie.setSameSite(CookieSameSite.valueOf(oidcConfig.authentication.cookieSameSite.name())); } context.response().addCookie(cookie); return cookie; @@ -1369,7 +1385,7 @@ private Uni buildLogoutRedirectUriUni(RoutingContext context, TenantConfig public Void apply(Void t) { String logoutUri = buildLogoutRedirectUri(configContext, idToken, context); LOG.debugf("Logout uri: %s", logoutUri); - throw new AuthenticationRedirectException(logoutUri); + throw new AuthenticationRedirectException(filterRedirect(context, configContext, logoutUri)); } }); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java index d5c5d730a745e4..2c6b8d5be7ad9a 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcUtils.java @@ -65,6 +65,7 @@ import io.vertx.core.http.HttpHeaders; import io.vertx.core.http.HttpMethod; import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.impl.CookieImpl; import io.vertx.core.http.impl.ServerCookie; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; @@ -491,7 +492,7 @@ static Uni removeSessionCookie(RoutingContext context, OidcTenantConfig oi } } - static String removeCookie(RoutingContext context, OidcTenantConfig oidcConfig, String cookieName) { + public static String removeCookie(RoutingContext context, OidcTenantConfig oidcConfig, String cookieName) { ServerCookie cookie = (ServerCookie) context.cookieMap().get(cookieName); String cookieValue = null; if (cookie != null) { @@ -786,4 +787,20 @@ public static boolean cacheUserInfoInIdToken(DefaultTenantConfigResolver resolve return resolver.getTokenStateManager() instanceof DefaultTokenStateManager && oidcConfig.tokenStateManager.encryptionRequired; } + + public static ServerCookie createCookie(RoutingContext context, OidcTenantConfig oidcConfig, + String name, String value, long maxAge) { + ServerCookie cookie = new CookieImpl(name, value); + cookie.setHttpOnly(true); + cookie.setSecure(oidcConfig.authentication.cookieForceSecure || context.request().isSSL()); + cookie.setMaxAge(maxAge); + LOG.debugf(name + " cookie 'max-age' parameter is set to %d", maxAge); + Authentication auth = oidcConfig.getAuthentication(); + OidcUtils.setCookiePath(context, oidcConfig.getAuthentication(), cookie); + if (auth.cookieDomain.isPresent()) { + cookie.setDomain(auth.getCookieDomain().get()); + } + context.response().addCookie(cookie); + return cookie; + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java index ce1c9b64eca997..a11fec4b2baefd 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/TenantConfigContext.java @@ -1,6 +1,7 @@ package io.quarkus.oidc.runtime; import java.nio.charset.StandardCharsets; +import java.util.List; import javax.crypto.KeyGenerator; import javax.crypto.SecretKey; @@ -10,6 +11,7 @@ import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcConfigurationMetadata; +import io.quarkus.oidc.OidcRedirectFilter; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.runtime.configuration.ConfigurationException; @@ -27,6 +29,8 @@ public class TenantConfigContext { */ final OidcTenantConfig oidcConfig; + final List redirectFilters; + /** * PKCE Secret Key */ @@ -46,6 +50,7 @@ public TenantConfigContext(OidcProvider client, OidcTenantConfig config) { public TenantConfigContext(OidcProvider client, OidcTenantConfig config, boolean ready) { this.provider = client; this.oidcConfig = config; + this.redirectFilters = TenantFeatureFinder.find(config, OidcRedirectFilter.class); this.ready = ready; boolean isService = OidcUtils.isServiceApp(config); @@ -159,6 +164,10 @@ public OidcTenantConfig getOidcTenantConfig() { return oidcConfig; } + public List getOidcRedirectFilters() { + return redirectFilters; + } + public OidcConfigurationMetadata getOidcMetadata() { return provider != null ? provider.getMetadata() : null; } diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java index 2915157d827e80..759473eea051a0 100644 --- a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java @@ -52,7 +52,7 @@ public String resolve(RoutingContext context) { return "tenant-autorefresh"; } - if (path.contains("tenant-refresh")) { + if (path.endsWith("tenant-refresh")) { return "tenant-refresh"; } diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/GlobalOidcRedirectFilter.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/GlobalOidcRedirectFilter.java new file mode 100644 index 00000000000000..cc97c22ae618e8 --- /dev/null +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/GlobalOidcRedirectFilter.java @@ -0,0 +1,19 @@ +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.OidcRedirectFilter; + +@ApplicationScoped +@Unremovable +public class GlobalOidcRedirectFilter implements OidcRedirectFilter { + + @Override + public void filter(OidcRedirectContext context) { + if (context.redirectUri().contains("/session-expired-page")) { + context.additionalQueryParams().add("redirect-filtered", "true,"); + } + } + +} diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/SessionExpiredOidcRedirectFilter.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/SessionExpiredOidcRedirectFilter.java new file mode 100644 index 00000000000000..c7672dc753d186 --- /dev/null +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/SessionExpiredOidcRedirectFilter.java @@ -0,0 +1,38 @@ +package io.quarkus.it.keycloak; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.jwt.Claims; + +import io.quarkus.arc.Unremovable; +import io.quarkus.oidc.AuthorizationCodeTokens; +import io.quarkus.oidc.OidcRedirectFilter; +import io.quarkus.oidc.TenantFeature; +import io.quarkus.oidc.runtime.OidcUtils; +import io.smallrye.jwt.build.Jwt; + +@ApplicationScoped +@Unremovable +@TenantFeature("tenant-refresh") +public class SessionExpiredOidcRedirectFilter implements OidcRedirectFilter { + + @Override + public void filter(OidcRedirectContext context) { + + if (!"tenant-refresh".equals(context.oidcTenantConfig().tenantId.get())) { + throw new RuntimeException("Invalid tenant id"); + } + + if (context.redirectUri().contains("/session-expired-page")) { + AuthorizationCodeTokens tokens = context.routingContext().get(AuthorizationCodeTokens.class.getName()); + String userName = OidcUtils.decodeJwtContent(tokens.getIdToken()).getString(Claims.preferred_username.name()); + String jwe = Jwt.preferredUserName(userName).jwe() + .encryptWithSecret(context.oidcTenantConfig().credentials.secret.get()); + OidcUtils.createCookie(context.routingContext(), context.oidcTenantConfig(), "session_expired", + jwe + "|" + context.oidcTenantConfig().tenantId.get(), 10); + + context.additionalQueryParams().add("session-expired", "true"); + } + } + +} diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantRefresh.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantRefresh.java index 4ea2986944d3fa..c1c4646559d673 100644 --- a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantRefresh.java +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantRefresh.java @@ -1,10 +1,19 @@ package io.quarkus.it.keycloak; import jakarta.inject.Inject; +import jakarta.ws.rs.CookieParam; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import org.eclipse.microprofile.jwt.Claims; +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.runtime.OidcUtils; +import io.quarkus.oidc.runtime.TenantConfigBean; import io.quarkus.security.Authenticated; +import io.smallrye.jwt.auth.principal.DefaultJWTParser; import io.vertx.ext.web.RoutingContext; @Path("/tenant-refresh") @@ -12,9 +21,33 @@ public class TenantRefresh { @Inject RoutingContext context; + @Inject + TenantConfigBean tenantConfig; + @Authenticated @GET public String getTenantRefresh() { return "Tenant Refresh, refreshed: " + (context.get("refresh_token_grant_response") != null); } + + @GET + @Path("/session-expired-page") + public String sessionExpired(@CookieParam("session_expired") String sessionExpired, + @QueryParam("session-expired") boolean expired, @QueryParam("redirect-filtered") String filtered) + throws Exception { + if (expired && filtered.equals("true,")) { + // Cookie format: jwt| + + String[] pair = sessionExpired.split("\\|"); + OidcTenantConfig oidcConfig = tenantConfig.getStaticTenantsConfig().get(pair[1]).getOidcTenantConfig(); + JsonWebToken jwt = new DefaultJWTParser().decrypt(pair[0], oidcConfig.credentials.secret.get()); + + OidcUtils.removeCookie(context, oidcConfig, "session_expired"); + + return jwt.getClaim(Claims.preferred_username) + ", your session has expired. " + + "Please login again at http://localhost:8081/" + oidcConfig.tenantId.get(); + } + + throw new RuntimeException("Invalid session expired page redirect"); + } } diff --git a/integration-tests/oidc-code-flow/src/main/resources/application.properties b/integration-tests/oidc-code-flow/src/main/resources/application.properties index 0d61acc332ab54..9ce1a549b48660 100644 --- a/integration-tests/oidc-code-flow/src/main/resources/application.properties +++ b/integration-tests/oidc-code-flow/src/main/resources/application.properties @@ -75,7 +75,7 @@ quarkus.oidc.tenant-3.application-type=web-app quarkus.oidc.tenant-logout.auth-server-url=${keycloak.url}/realms/logout-realm quarkus.oidc.tenant-logout.client-id=quarkus-app -quarkus.oidc.tenant-logout.credentials.secret=secret +quarkus.oidc.tenant-logout.credentials.secret=eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU quarkus.oidc.tenant-logout.application-type=web-app quarkus.oidc.tenant-logout.authentication.cookie-path=/tenant-logout quarkus.oidc.tenant-logout.logout.path=/tenant-logout/logout @@ -85,11 +85,11 @@ quarkus.oidc.tenant-logout.token.refresh-expired=true quarkus.oidc.tenant-refresh.auth-server-url=${keycloak.url}/realms/logout-realm quarkus.oidc.tenant-refresh.client-id=quarkus-app -quarkus.oidc.tenant-refresh.credentials.secret=secret +quarkus.oidc.tenant-refresh.credentials.secret=eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU quarkus.oidc.tenant-refresh.application-type=web-app quarkus.oidc.tenant-refresh.authentication.cookie-path=/tenant-refresh quarkus.oidc.tenant-refresh.authentication.session-age-extension=2M -quarkus.oidc.tenant-refresh.authentication.session-expired-path=/session-expired-page +quarkus.oidc.tenant-refresh.authentication.session-expired-path=/tenant-refresh/session-expired-page quarkus.oidc.tenant-refresh.token.refresh-expired=true quarkus.oidc.tenant-autorefresh.auth-server-url=${quarkus.oidc.auth-server-url} diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java index 1f62617f0c1728..6481db98a79930 100644 --- a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/CodeFlowTest.java @@ -751,8 +751,18 @@ public Boolean call() throws Exception { if (statusCode == 302) { assertNull(getSessionCookie(webClient, "tenant-refresh")); - assertEquals("http://localhost:8081/session-expired-page", - webResponse.getResponseHeaderValue("location")); + String redirect = webResponse.getResponseHeaderValue("location"); + assertTrue(redirect.equals( + "http://localhost:8081/tenant-refresh/session-expired-page?redirect-filtered=true%2C&session-expired=true") + || redirect.equals( + "http://localhost:8081/tenant-refresh/session-expired-page?session-expired=true&redirect-filtered=true%2C")); + assertNotNull(webClient.getCookieManager().getCookie("session_expired")); + webResponse = webClient.loadWebResponse( + new WebRequest(URI.create(redirect).toURL())); + assertEquals( + "alice, your session has expired. Please login again at http://localhost:8081/tenant-refresh", + webResponse.getContentAsString()); + assertNull(webClient.getCookieManager().getCookie("session_expired")); return true; } diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java index fd749a8f8668d1..338208e6e502b1 100644 --- a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java @@ -27,11 +27,11 @@ public class KeycloakRealmResourceManager implements QuarkusTestResourceLifecycl @Override public Map start() { - RealmRepresentation realm = createRealm(KEYCLOAK_REALM); + RealmRepresentation realm = createRealm(KEYCLOAK_REALM, "secret"); client.createRealm(realm); realms.add(realm); - RealmRepresentation logoutRealm = createRealm("logout-realm"); + RealmRepresentation logoutRealm = createRealm("logout-realm", "eUk1p7UB3nFiXZGUXi0uph1Y9p34YhBU"); // revoke refresh tokens so that they can only be used once logoutRealm.setRevokeRefreshToken(true); logoutRealm.setRefreshTokenMaxReuse(0); @@ -42,7 +42,7 @@ public Map start() { return Collections.emptyMap(); } - private static RealmRepresentation createRealm(String name) { + private static RealmRepresentation createRealm(String name, String defaultClientSecret) { RealmRepresentation realm = new RealmRepresentation(); realm.setRealm(name); @@ -62,7 +62,7 @@ private static RealmRepresentation createRealm(String name) { realm.getRoles().getRealm().add(new RoleRepresentation("admin", null, false)); realm.getRoles().getRealm().add(new RoleRepresentation("confidential", null, false)); - realm.getClients().add(createClient("quarkus-app")); + realm.getClients().add(createClient("quarkus-app", defaultClientSecret)); realm.getClients().add(createClientJwt("quarkus-app-jwt")); realm.getUsers().add(createUser("alice", "user")); realm.getUsers().add(createUser("admin", "user", "admin")); @@ -83,14 +83,14 @@ private static ClientRepresentation createClientJwt(String clientId) { return client; } - private static ClientRepresentation createClient(String clientId) { + private static ClientRepresentation createClient(String clientId, String secret) { ClientRepresentation client = new ClientRepresentation(); client.setClientId(clientId); client.setEnabled(true); client.setRedirectUris(Arrays.asList("*")); client.setClientAuthenticatorType("client-secret"); - client.setSecret("secret"); + client.setSecret(secret); return client; }