Skip to content

Commit 483f8fe

Browse files
authored
Merge pull request #48575 from sberyozkin/oidc_logout_post_method
Support for OIDC RP-Initiated form post logout mode
2 parents 9bc0ccc + c69ce54 commit 483f8fe

File tree

15 files changed

+276
-21
lines changed

15 files changed

+276
-21
lines changed

docs/src/main/asciidoc/security-oidc-expanded-configuration.adoc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,7 @@ Quarkus OIDC supports three standard OIDC logout options: <<rp-initiated-logout>
643643
|quarkus.oidc.logout.post-logout-path || Post logout path
644644
|quarkus.oidc.logout.post-logout-uri-param || Post logout uri param
645645
|quarkus.oidc.logout.extra-params || Logout extra params
646+
|quarkus.oidc.logout.logout-mode |query| Logout mode
646647
|====
647648

648649
`quarkus.oidc.logout.path` is a relative path where a user logout request should be sent to. For example, given `quarkus.oidc.logout.path=/logout`, a `Logout` link in the SPA page can point to `http://localhost:8080/logout`. This path can be virtual, you do not have to create a JAX-RS endpoint or route handler listening on `/logout`. But for the `quarkus.oidc.logout.path` be effective, it must be secured, see the https://quarkus.io/guides/security-oidc-code-flow-authentication#user-initiated-logout[User-initiated logout] section for more details.
@@ -653,6 +654,9 @@ For the post logout redirect to work, OIDC providers usually require registering
653654

654655
`quarkus.oidc.logout.post-logout-uri-param` and `quarkus.oidc.logout.extra-params` can be used to customize the RP-initiated logout query parameters, for example, Auth0 might expect Auth0-specific logout query parameters, see the https://quarkus.io/guides/security-oidc-code-flow-authentication#user-initiated-logout[User-initiated logout] section for more details.
655656

657+
By default, all the logout parameters are serialized as logout URL query parameters.
658+
Some OIDC providers may require that logout parameters are encoded as HTML form values and auto-submitted in the browser with the HTTP POST method and the `application/x-www-form-urlencoded` content type: set `quarkus.oidc.logout.logout-mode=form-post` in this case.
659+
656660
[[back-channel-logout]]
657661
=== Back-channel Logout
658662

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package io.quarkus.oidc;
2+
3+
import java.util.Map;
4+
5+
import io.quarkus.oidc.common.runtime.OidcConstants;
6+
import io.quarkus.oidc.runtime.OidcTenantConfig.Logout;
7+
8+
public final class LogoutUtils {
9+
10+
private static final String FORM_POST_LOGOUT_START = "<html>"
11+
+ " <head><title>Logout Form</title></head>"
12+
+ " <body onload=\"javascript:document.forms[0].submit()\">"
13+
+ " <form method=\"post\" action=\"";
14+
private static final String FORM_POST_LOGOUT_END = " </form></body></html>";
15+
16+
private LogoutUtils() {
17+
18+
}
19+
20+
public static String createFormPostLogout(Logout logoutConfig, String logoutUrl,
21+
String idToken, String postLogoutUrl, String postLogoutState) {
22+
StringBuilder sb = new StringBuilder();
23+
sb.append(FORM_POST_LOGOUT_START);
24+
sb.append(logoutUrl).append("\">");
25+
if (idToken != null) {
26+
addInput(sb, OidcConstants.LOGOUT_ID_TOKEN_HINT, idToken);
27+
}
28+
if (postLogoutUrl != null) {
29+
addInput(sb, logoutConfig.postLogoutUriParam(), postLogoutUrl);
30+
}
31+
if (postLogoutState != null) {
32+
addInput(sb, OidcConstants.LOGOUT_STATE, postLogoutState);
33+
}
34+
35+
Map<String, String> extraParams = logoutConfig.extraParams();
36+
if (extraParams != null) {
37+
for (Map.Entry<String, String> entry : extraParams.entrySet()) {
38+
addInput(sb, entry.getKey(), entry.getValue());
39+
}
40+
}
41+
sb.append(FORM_POST_LOGOUT_END);
42+
return sb.toString();
43+
}
44+
45+
private static void addInput(StringBuilder sb, String name, String value) {
46+
sb.append("<input type=\"hidden\" name=\"").append(name).append("\" ")
47+
.append("value=\"").append(value).append("\"")
48+
.append("/>");
49+
}
50+
51+
}

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcTenantConfig.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,8 @@ public static class Logout implements io.quarkus.oidc.runtime.OidcTenantConfig.L
488488
*/
489489
Optional<Set<ClearSiteData>> clearSiteData = Optional.of(Set.of());
490490

491+
LogoutMode logoutMode = LogoutMode.QUERY;
492+
491493
/**
492494
* Back-Channel Logout configuration
493495
*/
@@ -552,6 +554,7 @@ private void addConfigMappingValues(io.quarkus.oidc.runtime.OidcTenantConfig.Log
552554
postLogoutUriParam = mapping.postLogoutUriParam();
553555
extraParams = mapping.extraParams();
554556
clearSiteData = mapping.clearSiteData();
557+
logoutMode = mapping.logoutMode();
555558
backchannel.addConfigMappingValues(mapping.backchannel());
556559
frontchannel.addConfigMappingValues(mapping.frontchannel());
557560
}
@@ -590,6 +593,11 @@ public io.quarkus.oidc.runtime.OidcTenantConfig.Frontchannel frontchannel() {
590593
public Optional<Set<ClearSiteData>> clearSiteData() {
591594
return clearSiteData;
592595
}
596+
597+
@Override
598+
public LogoutMode logoutMode() {
599+
return logoutMode;
600+
}
593601
}
594602

595603
/**

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/CodeAuthenticationMechanism.java

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import io.quarkus.oidc.AuthorizationCodeTokens;
2929
import io.quarkus.oidc.IdTokenCredential;
3030
import io.quarkus.oidc.JavaScriptRequestChecker;
31+
import io.quarkus.oidc.LogoutUtils;
3132
import io.quarkus.oidc.OidcRedirectFilter;
3233
import io.quarkus.oidc.OidcRedirectFilter.OidcRedirectContext;
3334
import io.quarkus.oidc.OidcTenantConfig;
@@ -39,6 +40,7 @@
3940
import io.quarkus.oidc.common.runtime.OidcConstants;
4041
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication;
4142
import io.quarkus.oidc.runtime.OidcTenantConfig.Authentication.ResponseMode;
43+
import io.quarkus.oidc.runtime.OidcTenantConfig.Logout.LogoutMode;
4244
import io.quarkus.security.AuthenticationCompletionException;
4345
import io.quarkus.security.AuthenticationFailedException;
4446
import io.quarkus.security.AuthenticationRedirectException;
@@ -1435,8 +1437,10 @@ private Uni<AuthorizationCodeTokens> getCodeFlowTokensUni(RoutingContext context
14351437

14361438
private String buildLogoutRedirectUri(TenantConfigContext configContext, String idToken, RoutingContext context) {
14371439
String logoutPath = configContext.provider().getMetadata().getEndSessionUri();
1440+
Map<String, String> extraParams = configContext.oidcConfig().logout().extraParams();
14381441
StringBuilder logoutUri = new StringBuilder(logoutPath);
1439-
if (idToken != null || configContext.oidcConfig().logout().postLogoutPath().isPresent()) {
1442+
if (idToken != null || configContext.oidcConfig().logout().postLogoutPath().isPresent()
1443+
|| (extraParams != null && !extraParams.isEmpty())) {
14401444
logoutUri.append("?");
14411445
}
14421446
if (idToken != null) {
@@ -1477,10 +1481,29 @@ private Uni<Void> buildLogoutRedirectUriUni(RoutingContext context, TenantConfig
14771481
.map(new Function<Void, Void>() {
14781482
@Override
14791483
public Void apply(Void t) {
1480-
String logoutUri = buildLogoutRedirectUri(configContext, idToken, context);
1481-
LOG.debugf("Logout uri: %s", logoutUri);
1482-
throw new AuthenticationRedirectException(
1483-
filterRedirect(context, configContext, logoutUri, Redirect.Location.OIDC_LOGOUT));
1484+
if (configContext.oidcConfig().logout().logoutMode() == LogoutMode.QUERY) {
1485+
String logoutUri = buildLogoutRedirectUri(configContext, idToken, context);
1486+
LOG.debugf("Logout uri: %s", logoutUri);
1487+
throw new AuthenticationRedirectException(
1488+
filterRedirect(context, configContext, logoutUri, Redirect.Location.OIDC_LOGOUT));
1489+
} else {
1490+
String postLogoutUrl = null;
1491+
String postLogoutState = null;
1492+
if (configContext.oidcConfig().logout().postLogoutPath().isPresent()) {
1493+
postLogoutUrl = buildUri(context, isForceHttps(configContext.oidcConfig()),
1494+
configContext.oidcConfig().logout().postLogoutPath().get());
1495+
postLogoutState = generatePostLogoutState(context, configContext);
1496+
}
1497+
1498+
String logoutUrl = filterRedirect(context, configContext,
1499+
configContext.provider().getMetadata().getEndSessionUri(), Redirect.Location.OIDC_LOGOUT);
1500+
// Target URL is embedded in the form post payload
1501+
String formPostLogout = LogoutUtils.createFormPostLogout(configContext.oidcConfig().logout(),
1502+
logoutUrl, idToken,
1503+
postLogoutUrl, postLogoutState);
1504+
LOG.debugf("Initiating form post logout");
1505+
throw new AuthenticationRedirectException(200, formPostLogout);
1506+
}
14841507
}
14851508
});
14861509
}

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcTenantConfig.java

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,25 @@ public String directive() {
339339
* Clear-Site-Data header directives
340340
*/
341341
Optional<Set<ClearSiteData>> clearSiteData();
342+
343+
enum LogoutMode {
344+
/**
345+
* Logout parameters are encoded in the query string
346+
*/
347+
QUERY,
348+
349+
/**
350+
* Logout parameters are encoded as HTML form values that are auto-submitted in the browser
351+
* and transmitted by the HTTP POST method using the application/x-www-form-urlencoded content type
352+
*/
353+
FORM_POST
354+
}
355+
356+
/**
357+
* Logout mode
358+
*/
359+
@WithDefault("query")
360+
LogoutMode logoutMode();
342361
}
343362

344363
interface Backchannel {

extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/builders/LogoutConfigBuilder.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import io.quarkus.oidc.OidcTenantConfigBuilder;
1313
import io.quarkus.oidc.runtime.OidcTenantConfig;
1414
import io.quarkus.oidc.runtime.OidcTenantConfig.Logout.ClearSiteData;
15+
import io.quarkus.oidc.runtime.OidcTenantConfig.Logout.LogoutMode;
1516

1617
/**
1718
* Builder for the {@link OidcTenantConfig.Logout}.
@@ -20,7 +21,8 @@ public final class LogoutConfigBuilder {
2021
private record LogoutImpl(Optional<String> path, Optional<String> postLogoutPath, String postLogoutUriParam,
2122
Map<String, String> extraParams, OidcTenantConfig.Backchannel backchannel,
2223
OidcTenantConfig.Frontchannel frontchannel,
23-
Optional<Set<ClearSiteData>> clearSiteData) implements OidcTenantConfig.Logout {
24+
Optional<Set<ClearSiteData>> clearSiteData,
25+
LogoutMode logoutMode) implements OidcTenantConfig.Logout {
2426
}
2527

2628
private record FrontchannelImpl(Optional<String> path) implements OidcTenantConfig.Frontchannel {
@@ -34,6 +36,7 @@ private record FrontchannelImpl(Optional<String> path) implements OidcTenantConf
3436
private OidcTenantConfig.Backchannel backchannel;
3537
private OidcTenantConfig.Frontchannel frontchannel;
3638
private Optional<Set<ClearSiteData>> clearSiteData = Optional.of(new HashSet<>());
39+
private LogoutMode logoutMode;
3740

3841
public LogoutConfigBuilder() {
3942
this(new OidcTenantConfigBuilder());
@@ -51,6 +54,7 @@ public LogoutConfigBuilder(OidcTenantConfigBuilder builder) {
5154
this.backchannel = logout.backchannel();
5255
this.frontchannel = logout.frontchannel();
5356
this.clearSiteData = logout.clearSiteData();
57+
this.logoutMode = logout.logoutMode();
5458
}
5559

5660
/**
@@ -132,6 +136,21 @@ public LogoutConfigBuilder clearSiteData(List<ClearSiteData> directives) {
132136
return this;
133137
}
134138

139+
public LogoutConfigBuilder logoutMode() {
140+
this.logoutMode(LogoutMode.QUERY);
141+
return this;
142+
}
143+
144+
/**
145+
* @param clear site data directives {@link OidcTenantConfig.Logout#clearSiteData()}
146+
* @return this builder
147+
*/
148+
public LogoutConfigBuilder logoutMode(LogoutMode logoutMode) {
149+
Objects.requireNonNull(logoutMode);
150+
this.logoutMode = logoutMode;
151+
return this;
152+
}
153+
135154
/**
136155
* @param backchannel {@link OidcTenantConfig.Logout#backchannel()}
137156
* @return this builder
@@ -162,7 +181,7 @@ public OidcTenantConfigBuilder end() {
162181
*/
163182
public OidcTenantConfig.Logout build() {
164183
return new LogoutImpl(path, postLogoutPath, postLogoutUriParam, Map.copyOf(extraParams), backchannel, frontchannel,
165-
clearSiteData);
184+
clearSiteData, logoutMode);
166185
}
167186

168187
/**

extensions/oidc/runtime/src/test/java/io/quarkus/oidc/runtime/OidcTenantConfigImpl.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ enum ConfigMappingMethods {
162162
LOGOUT_POST_LOGOUT_PATH,
163163
LOGOUT_POST_LOGOUT_URI_PARAM,
164164
LOGOUT_CLEAR_SITE_DATA,
165+
LOGOUT_MODE,
165166
LOGOUT_EXTRA_PARAMS,
166167
LOGOUT_BACK_CHANNEL,
167168
LOGOUT_FRONT_CHANNEL,
@@ -506,6 +507,12 @@ public Optional<Set<ClearSiteData>> clearSiteData() {
506507
return Optional.of(Set.of());
507508
}
508509

510+
@Override
511+
public LogoutMode logoutMode() {
512+
invocationsRecorder.put(ConfigMappingMethods.LOGOUT_MODE, true);
513+
return LogoutMode.QUERY;
514+
}
515+
509516
@Override
510517
public Backchannel backchannel() {
511518
invocationsRecorder.put(ConfigMappingMethods.LOGOUT_BACK_CHANNEL, true);

extensions/resteasy-classic/resteasy/runtime/src/main/java/io/quarkus/resteasy/runtime/AuthenticationRedirectExceptionMapper.java

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,34 @@
22

33
import jakarta.ws.rs.core.HttpHeaders;
44
import jakarta.ws.rs.core.Response;
5+
import jakarta.ws.rs.core.Response.ResponseBuilder;
56
import jakarta.ws.rs.ext.ExceptionMapper;
67
import jakarta.ws.rs.ext.Provider;
78

9+
import org.jboss.logging.Logger;
10+
811
import io.quarkus.security.AuthenticationRedirectException;
912

1013
@Provider
1114
public class AuthenticationRedirectExceptionMapper implements ExceptionMapper<AuthenticationRedirectException> {
15+
private static final Logger log = Logger.getLogger(AuthenticationRedirectExceptionMapper.class);
1216

1317
@Override
1418
public Response toResponse(AuthenticationRedirectException ex) {
15-
return Response.status(ex.getCode())
16-
.header(HttpHeaders.LOCATION, ex.getRedirectUri())
19+
20+
ResponseBuilder builder = Response.status(ex.getCode())
1721
.header(HttpHeaders.CACHE_CONTROL, "no-store")
18-
.header("Pragma", "no-cache")
19-
.build();
22+
.header("Pragma", "no-cache");
23+
if (ex.getCode() == 200) {
24+
// The target URL is embedded in the auto-submitted form post payload
25+
log.debugf("Form post redirect to %s", ex.getRedirectUri());
26+
builder.entity(ex.getRedirectUri())
27+
.type("text/html; charset=UTF-8");
28+
} else {
29+
log.debugf("Redirect to %s ", ex.getRedirectUri());
30+
builder.header(HttpHeaders.LOCATION, ex.getRedirectUri());
31+
}
32+
return builder.build();
2033
}
2134

2235
}

extensions/resteasy-reactive/rest/runtime/src/main/java/io/quarkus/resteasy/reactive/server/runtime/exceptionmappers/AuthenticationRedirectExceptionMapper.java

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,31 @@
22

33
import jakarta.ws.rs.core.HttpHeaders;
44
import jakarta.ws.rs.core.Response;
5+
import jakarta.ws.rs.core.Response.ResponseBuilder;
56
import jakarta.ws.rs.ext.ExceptionMapper;
67

8+
import org.jboss.logging.Logger;
9+
710
import io.quarkus.security.AuthenticationRedirectException;
811

912
public class AuthenticationRedirectExceptionMapper implements ExceptionMapper<AuthenticationRedirectException> {
13+
private static final Logger log = Logger.getLogger(AuthenticationRedirectExceptionMapper.class);
1014

1115
@Override
1216
public Response toResponse(AuthenticationRedirectException ex) {
13-
return Response.status(ex.getCode())
14-
.header(HttpHeaders.LOCATION, ex.getRedirectUri())
17+
18+
ResponseBuilder builder = Response.status(ex.getCode())
1519
.header(HttpHeaders.CACHE_CONTROL, "no-store")
16-
.header("Pragma", "no-cache")
17-
.build();
20+
.header("Pragma", "no-cache");
21+
if (ex.getCode() == 200) {
22+
// The target URL is embedded in the auto-submitted form post payload
23+
log.debugf("Form post redirect to %s", ex.getRedirectUri());
24+
builder.entity(ex.getRedirectUri())
25+
.type("text/html; charset=UTF-8");
26+
} else {
27+
log.debugf("Redirect to %s ", ex.getRedirectUri());
28+
builder.header(HttpHeaders.LOCATION, ex.getRedirectUri());
29+
}
30+
return builder.build();
1831
}
19-
2032
}

extensions/vertx-http/runtime/src/main/java/io/quarkus/vertx/http/runtime/security/HttpSecurityRecorder.java

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
import java.nio.charset.StandardCharsets;
1313
import java.nio.file.Files;
1414
import java.nio.file.Path;
15-
import java.util.*;
15+
import java.util.HashMap;
16+
import java.util.HashSet;
17+
import java.util.List;
18+
import java.util.Map;
19+
import java.util.Properties;
20+
import java.util.Set;
1621
import java.util.concurrent.CompletionException;
1722
import java.util.function.BiConsumer;
1823
import java.util.function.Consumer;
@@ -45,6 +50,7 @@
4550
import io.smallrye.mutiny.subscription.UniSubscriber;
4651
import io.smallrye.mutiny.subscription.UniSubscription;
4752
import io.smallrye.mutiny.tuples.Functions;
53+
import io.vertx.core.AsyncResult;
4854
import io.vertx.core.Context;
4955
import io.vertx.core.Handler;
5056
import io.vertx.core.Vertx;
@@ -212,10 +218,25 @@ public void accept(Throwable throwable) {
212218
proceed(throwable);
213219
} else if (throwable instanceof AuthenticationRedirectException redirectEx) {
214220
event.response().setStatusCode(redirectEx.getCode());
215-
event.response().headers().set(HttpHeaders.LOCATION, redirectEx.getRedirectUri());
216221
event.response().headers().set(HttpHeaders.CACHE_CONTROL, "no-store");
217222
event.response().headers().set("Pragma", "no-cache");
218-
proceed(throwable);
223+
224+
if (redirectEx.getCode() == 200) {
225+
// The target URL is embedded in the auto-submitted form post payload
226+
log.debugf("Form post redirect to %s", redirectEx.getRedirectUri());
227+
event.response().putHeader("Content-Type", "text/html; charset=UTF-8");
228+
event.response().write(redirectEx.getRedirectUri()).onComplete(
229+
new Handler<AsyncResult<Void>>() {
230+
@Override
231+
public void handle(AsyncResult<Void> v) {
232+
proceed(redirectEx);
233+
}
234+
});
235+
} else {
236+
log.debugf("Redirect to %s ", redirectEx.getRedirectUri());
237+
event.response().headers().set(HttpHeaders.LOCATION, redirectEx.getRedirectUri());
238+
proceed(throwable);
239+
}
219240
} else {
220241
event.put(OTHER_AUTHENTICATION_FAILURE, Boolean.TRUE);
221242
event.fail(throwable);

0 commit comments

Comments
 (0)