Skip to content

Commit

Permalink
Merge pull request quarkusio#40600 from sberyozkin/oidc_redirect_filter
Browse files Browse the repository at this point in the history
Introduce OidcRedirectFilter
  • Loading branch information
sberyozkin authored May 17, 2024
2 parents 1b085b9 + 9a910fa commit 6cf3b7e
Show file tree
Hide file tree
Showing 12 changed files with 330 additions and 29 deletions.
129 changes: 129 additions & 0 deletions docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 <<oidc-redirect-filters>> 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.
Expand All @@ -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 <<customize-authentication-requests>> 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|<tenant id>
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.
Expand Down Expand Up @@ -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 <<oidc-redirect-filters>> section explaining how a custom `OidcRedirectFilter` can be used to customize OIDC redirects, including those to the session expired pages.
====


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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
* configuration, redirect uri and additional query parameters.
*
*/
void filter(OidcRedirectContext redirectContext);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -227,8 +229,10 @@ public Uni<SecurityIdentity> 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(
Expand All @@ -242,6 +246,24 @@ public Uni<SecurityIdentity> 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<SecurityIdentity> stateParamIsMissing(OidcTenantConfig oidcTenantConfig, RoutingContext context,
Map<String, Cookie> cookies, boolean multipleStateQueryParams) {
if (multipleStateQueryParams) {
Expand Down Expand Up @@ -432,7 +454,8 @@ private Uni<SecurityIdentity> 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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1150,18 +1175,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;
Expand Down Expand Up @@ -1368,7 +1384,7 @@ private Uni<Void> 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));
}
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -492,7 +493,7 @@ static Uni<Void> 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) {
Expand Down Expand Up @@ -787,4 +788,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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -27,6 +29,8 @@ public class TenantConfigContext {
*/
final OidcTenantConfig oidcConfig;

final List<OidcRedirectFilter> redirectFilters;

/**
* PKCE Secret Key
*/
Expand All @@ -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);
Expand Down Expand Up @@ -159,6 +164,10 @@ public OidcTenantConfig getOidcTenantConfig() {
return oidcConfig;
}

public List<OidcRedirectFilter> getOidcRedirectFilters() {
return redirectFilters;
}

public OidcConfigurationMetadata getOidcMetadata() {
return provider != null ? provider.getMetadata() : null;
}
Expand Down
Loading

0 comments on commit 6cf3b7e

Please sign in to comment.