diff --git a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc index 835ac82850824..07df75783fe86 100644 --- a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc @@ -508,6 +508,25 @@ Absolute `Back-Channel Logout` URL is calculated by adding `quarkus.oidc.back-ch Note that you will also need to configure a token age property for the logout token verification to succeed if your OpenID Connect Provider does not set an expiry claim in the current logout token, for example, `quarkus.oidc.token.age=10S` sets a number of seconds that must not elapse since the logout token's `iat` (issued at) time to 10. +[[front-channel-logout]] +==== Front-Channel Logout + +link:https://openid.net/specs/openid-connect-frontchannel-1_0.html[Front-Channel Logout] can be used to logout the current user directly from the user agent. + +You can configure Quarkus to support `Front-Channel Logout` as follows: + +[source,properties] +---- +quarkus.oidc.auth-server-url=http://localhost:8180/realms/quarkus +quarkus.oidc.client-id=frontend +quarkus.oidc.credentials.secret=secret +quarkus.oidc.application-type=web-app + +quarkus.oidc.logout.frontchannel.path=/front-channel-logout +---- + +This path will be compared against the current request's path and the user willbe logged out if these paths match. + [[local-logout]] ==== Local Logout diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java index 1b14467645b86..ce3599f8f0efc 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java @@ -65,4 +65,6 @@ public final class OidcConstants { public static final String BACK_CHANNEL_EVENTS_CLAIM = "events"; public static final String BACK_CHANNEL_EVENT_NAME = "http://schemas.openid.net/event/backchannel-logout"; public static final String BACK_CHANNEL_LOGOUT_SID_CLAIM = "sid"; + public static final String FRONT_CHANNEL_LOGOUT_SID_PARAM = "sid"; + public static final String ID_TOKEN_SID_CLAIM = "sid"; } 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 7ddaf7da18860..9d97071efe195 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 @@ -157,7 +157,7 @@ public void setIncludeClientId(boolean includeClientId) { public Token token = new Token(); /** - * RP Initiated and BackChannel Logout configuration + * RP Initiated, BackChannel and FrontChannel Logout configuration */ @ConfigItem public Logout logout = new Logout(); @@ -241,6 +241,12 @@ public static class Logout { @ConfigItem public Backchannel backchannel = new Backchannel(); + /** + * Front-Channel Logout configuration + */ + @ConfigItem + public Frontchannel frontchannel = new Frontchannel(); + public void setPath(Optional path) { this.path = path; } @@ -280,6 +286,14 @@ public Backchannel getBackchannel() { public void setBackchannel(Backchannel backchannel) { this.backchannel = backchannel; } + + public Frontchannel getFrontchannel() { + return frontchannel; + } + + public void setFrontchannel(Frontchannel frontchannel) { + this.frontchannel = frontchannel; + } } @ConfigGroup @@ -299,6 +313,23 @@ public String getPath() { } } + @ConfigGroup + public static class Frontchannel { + /** + * The relative path of the Front-Channel Logout endpoint at the application. + */ + @ConfigItem + public Optional path = Optional.empty(); + + public void setPath(Optional path) { + this.path = path; + } + + public String getPath() { + return path.get(); + } + } + /** * Default Authorization Code token state manager configuration */ diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/SecurityEvent.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/SecurityEvent.java index e03e47d4d4977..078d2d6c8f036 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/SecurityEvent.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/SecurityEvent.java @@ -1,5 +1,7 @@ package io.quarkus.oidc; +import java.util.Map; + import io.quarkus.security.identity.SecurityIdentity; /** @@ -26,15 +28,41 @@ public enum Type { /** * OIDC Logout event is reported when the current user has started an RP-initiated OIDC logout flow. */ - OIDC_LOGOUT_RP_INITIATED + OIDC_LOGOUT_RP_INITIATED, + + /** + * OIDC BackChannel Logout initiated event is reported when the BackChannel logout request to logout the current user + * has been received. + */ + OIDC_BACKCHANNEL_LOGOUT_INITIATED, + + /** + * OIDC BackChannel Logout completed event is reported when the current user's session has been removed due to a pending + * OIDC + * BackChannel logout request. + */ + OIDC_BACKCHANNEL_LOGOUT_COMPLETED, + /** + * OIDC FrontChannel Logout event is reported when the current user's session has been removed due to an OIDC + * FrontChannel logout request. + */ + OIDC_FRONTCHANNEL_LOGOUT_COMPLETED } private final Type eventType; private final SecurityIdentity securityIdentity; + private final Map eventProperties; public SecurityEvent(Type eventType, SecurityIdentity securityIdentity) { this.eventType = eventType; this.securityIdentity = securityIdentity; + this.eventProperties = Map.of(); + } + + public SecurityEvent(Type eventType, Map eventProperties) { + this.eventType = eventType; + this.securityIdentity = null; + this.eventProperties = eventProperties; } public Type getEventType() { @@ -44,4 +72,8 @@ public Type getEventType() { public SecurityIdentity getSecurityIdentity() { return securityIdentity; } + + public Map getEventProperties() { + return eventProperties; + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BackChannelLogoutHandler.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BackChannelLogoutHandler.java index 3d8a35d026b8b..77057a83f3118 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BackChannelLogoutHandler.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BackChannelLogoutHandler.java @@ -1,5 +1,6 @@ package io.quarkus.oidc.runtime; +import java.util.Map; import java.util.function.Consumer; import javax.enterprise.event.Observes; @@ -10,6 +11,8 @@ import org.jose4j.jwt.consumer.InvalidJwtException; import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.SecurityEvent; +import io.quarkus.oidc.SecurityEvent.Type; import io.quarkus.oidc.common.runtime.OidcConstants; import io.vertx.core.Handler; import io.vertx.core.MultiMap; @@ -84,6 +87,11 @@ public void accept(MultiMap form) { if (verifyLogoutTokenClaims(result)) { resolver.getBackChannelLogoutTokens().put(oidcTenantConfig.tenantId.get(), result); + if (resolver.isSecurityEventObserved()) { + resolver.getSecurityEvent().fire( + new SecurityEvent(Type.OIDC_BACKCHANNEL_LOGOUT_INITIATED, + Map.of(OidcConstants.BACK_CHANNEL_LOGOUT_TOKEN, result))); + } context.response().setStatusCode(200); } else { context.response().setStatusCode(400); 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 a5c541034b5b4..c9a85f84bfdb1 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 @@ -19,6 +19,7 @@ import java.util.regex.Pattern; import org.eclipse.microprofile.jwt.Claims; +import org.eclipse.microprofile.jwt.JsonWebToken; import org.jboss.logging.Logger; import org.jose4j.jwt.consumer.ErrorCodes; import org.jose4j.jwt.consumer.InvalidJwtException; @@ -232,20 +233,6 @@ private Uni reAuthenticate(Cookie sessionCookie, .chain(new Function>() { @Override public Uni apply(AuthorizationCodeTokens session) { - if (isBackChannelLogoutPendingAndValid(configContext, session.getIdToken())) { - LOG.debug("Performing a requested back-channel logout"); - return OidcUtils - .removeSessionCookie(context, configContext.oidcConfig, sessionCookie.getName(), - resolver.getTokenStateManager()) - .chain(new Function>() { - @Override - public Uni apply(Void t) { - return Uni.createFrom().nullItem(); - } - }); - - } - context.put(OidcConstants.ACCESS_TOKEN_VALUE, session.getAccessToken()); context.put(AuthorizationCodeTokens.class.getName(), session); // Default token state manager may have encrypted ID token when it was saved in a cookie @@ -262,6 +249,21 @@ public Uni apply(SecurityIdentity identity) { return buildLogoutRedirectUriUni(context, configContext, session.getIdToken()); } + if (isBackChannelLogoutPendingAndValid(configContext, identity) + || isFrontChannelLogoutValid(context, configContext, + identity)) { + return OidcUtils + .removeSessionCookie(context, configContext.oidcConfig, + sessionCookie.getName(), + resolver.getTokenStateManager()) + .map(new Function() { + @Override + public Void apply(Void t) { + throw new LogoutException(); + } + }); + + } return VOID_UNI; } }).onFailure() @@ -272,6 +274,10 @@ public Uni apply(Throwable t) { LOG.debug("Redirecting after the reauthentication"); return Uni.createFrom().failure((AuthenticationRedirectException) t); } + if (t instanceof LogoutException) { + LOG.debugf("User has been logged out, authentication challenge is required"); + return Uni.createFrom().failure(new AuthenticationFailedException(t)); + } if (!(t instanceof TokenAutoRefreshException)) { boolean expired = (t.getCause() instanceof InvalidJwtException) @@ -336,47 +342,65 @@ private static String decryptIdTokenIfEncryptedByProvider(TenantConfigContext re return token; } - private boolean isBackChannelLogoutPendingAndValid(TenantConfigContext configContext, String idToken) { + private boolean isBackChannelLogoutPendingAndValid(TenantConfigContext configContext, SecurityIdentity identity) { TokenVerificationResult backChannelLogoutTokenResult = resolver.getBackChannelLogoutTokens() .remove(configContext.oidcConfig.getTenantId().get()); if (backChannelLogoutTokenResult != null) { - // Verify IdToken signature first before comparing the claim values - try { - TokenVerificationResult idTokenResult = configContext.provider.verifyJwtToken(idToken); - - String idTokenIss = idTokenResult.localVerificationResult.getString(Claims.iss.name()); - String logoutTokenIss = backChannelLogoutTokenResult.localVerificationResult.getString(Claims.iss.name()); - if (logoutTokenIss != null && !logoutTokenIss.equals(idTokenIss)) { - LOG.debugf("Logout token issuer does not match the ID token issuer"); - return false; - } - String idTokenSub = idTokenResult.localVerificationResult.getString(Claims.sub.name()); - String logoutTokenSub = backChannelLogoutTokenResult.localVerificationResult.getString(Claims.sub.name()); - if (logoutTokenSub != null && idTokenSub != null && !logoutTokenSub.equals(idTokenSub)) { - LOG.debugf("Logout token subject does not match the ID token subject"); - return false; - } - String idTokenSid = idTokenResult.localVerificationResult - .getString(OidcConstants.BACK_CHANNEL_LOGOUT_SID_CLAIM); - String logoutTokenSid = backChannelLogoutTokenResult.localVerificationResult - .getString(OidcConstants.BACK_CHANNEL_LOGOUT_SID_CLAIM); - if (logoutTokenSid != null && idTokenSid != null && !logoutTokenSid.equals(idTokenSid)) { - LOG.debugf("Logout token session id does not match the ID token session id"); - return false; - } - } catch (InvalidJwtException ex) { - // Let IdentityProvider deal with it again, but just removing the session cookie without - // doing a logout token check against a verified ID token is not possible. - LOG.debugf("Unable to complete the back channel logout request for the tenant %s", - configContext.oidcConfig.tenantId.get()); + JsonObject idTokenJson = OidcUtils.decodeJwtContent(((JsonWebToken) (identity.getPrincipal())).getRawToken()); + String idTokenIss = idTokenJson.getString(Claims.iss.name()); + String logoutTokenIss = backChannelLogoutTokenResult.localVerificationResult.getString(Claims.iss.name()); + if (logoutTokenIss != null && !logoutTokenIss.equals(idTokenIss)) { + LOG.debugf("Logout token issuer does not match the ID token issuer"); return false; } + String idTokenSub = idTokenJson.getString(Claims.sub.name()); + String logoutTokenSub = backChannelLogoutTokenResult.localVerificationResult.getString(Claims.sub.name()); + if (logoutTokenSub != null && idTokenSub != null && !logoutTokenSub.equals(idTokenSub)) { + LOG.debugf("Logout token subject does not match the ID token subject"); + return false; + } + String idTokenSid = idTokenJson.getString(OidcConstants.ID_TOKEN_SID_CLAIM); + String logoutTokenSid = backChannelLogoutTokenResult.localVerificationResult + .getString(OidcConstants.BACK_CHANNEL_LOGOUT_SID_CLAIM); + if (logoutTokenSid != null && idTokenSid != null && !logoutTokenSid.equals(idTokenSid)) { + LOG.debugf("Logout token session id does not match the ID token session id"); + return false; + } + LOG.debugf("Frontchannel logout request for the tenant %s has been completed", + configContext.oidcConfig.tenantId.get()); + + fireEvent(SecurityEvent.Type.OIDC_BACKCHANNEL_LOGOUT_COMPLETED, identity); return true; } return false; } + private boolean isFrontChannelLogoutValid(RoutingContext context, TenantConfigContext configContext, + SecurityIdentity identity) { + if (isEqualToRequestPath(configContext.oidcConfig.logout.frontchannel.path, context, configContext)) { + JsonObject idTokenJson = OidcUtils.decodeJwtContent(((JsonWebToken) (identity.getPrincipal())).getRawToken()); + + String idTokenIss = idTokenJson.getString(Claims.iss.name()); + List frontChannelIss = context.queryParam(Claims.iss.name()); + if (frontChannelIss != null && frontChannelIss.size() == 1 && !frontChannelIss.get(0).equals(idTokenIss)) { + LOG.debugf("Frontchannel issuer parameter does not match the ID token issuer"); + return false; + } + String idTokenSid = idTokenJson.getString(OidcConstants.ID_TOKEN_SID_CLAIM); + List frontChannelSid = context.queryParam(OidcConstants.FRONT_CHANNEL_LOGOUT_SID_PARAM); + if (frontChannelSid != null && frontChannelSid.size() == 1 && !frontChannelSid.get(0).equals(idTokenSid)) { + LOG.debugf("Frontchannel session id parameter does not match the ID token session id"); + return false; + } + LOG.debugf("Frontchannel logout request for the tenant %s has been completed", + configContext.oidcConfig.tenantId.get()); + fireEvent(SecurityEvent.Type.OIDC_FRONTCHANNEL_LOGOUT_COMPLETED, identity); + return true; + } + return false; + } + private boolean isInternalIdToken(String idToken, TenantConfigContext configContext) { if (!configContext.oidcConfig.authentication.idTokenRequired.orElse(true)) { JsonObject headers = OidcUtils.decodeJwtHeaders(idToken); @@ -888,11 +912,12 @@ private String buildUri(RoutingContext context, boolean forceHttps, String autho } private boolean isLogout(RoutingContext context, TenantConfigContext configContext) { - Optional logoutPath = configContext.oidcConfig.logout.path; + return isEqualToRequestPath(configContext.oidcConfig.logout.path, context, configContext); + } - if (logoutPath.isPresent()) { - return context.request().absoluteURI().equals( - buildUri(context, false, logoutPath.get())); + private boolean isEqualToRequestPath(Optional path, RoutingContext context, TenantConfigContext configContext) { + if (path.isPresent()) { + return context.request().path().equals(path.get()); } return false; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/LogoutException.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/LogoutException.java new file mode 100644 index 0000000000000..2dc9a1e8310db --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/LogoutException.java @@ -0,0 +1,5 @@ +package io.quarkus.oidc.runtime; + +public class LogoutException extends RuntimeException { + +} diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java index ad09117d4d1ca..1473b7fb802a9 100644 --- a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java @@ -23,7 +23,7 @@ public String resolve(RoutingContext context) { if (path.endsWith("code-flow-encrypted-id-token-pem")) { return "code-flow-encrypted-id-token-pem"; } - if (path.endsWith("code-flow-form-post")) { + if (path.endsWith("code-flow-form-post") || path.endsWith("code-flow-form-post/front-channel-logout")) { return "code-flow-form-post"; } if (path.endsWith("code-flow-user-info-only")) { diff --git a/integration-tests/oidc-wiremock/src/main/resources/application.properties b/integration-tests/oidc-wiremock/src/main/resources/application.properties index eadda60bd9e56..ee4cc8c82d988 100644 --- a/integration-tests/oidc-wiremock/src/main/resources/application.properties +++ b/integration-tests/oidc-wiremock/src/main/resources/application.properties @@ -47,6 +47,7 @@ quarkus.oidc.code-flow-form-post.token-path=${keycloak.url}/realms/quarkus/token # reuse the wiremock JWK endpoint stub for the `quarkus` realm - it is the same for the query and form post response mode quarkus.oidc.code-flow-form-post.jwks-path=${keycloak.url}/realms/quarkus/protocol/openid-connect/certs quarkus.oidc.code-flow-form-post.logout.backchannel.path=/back-channel-logout +quarkus.oidc.code-flow-form-post.logout.frontchannel.path=/code-flow-form-post/front-channel-logout quarkus.oidc.code-flow-user-info-only.auth-server-url=${keycloak.url}/realms/quarkus/ @@ -127,6 +128,9 @@ quarkus.log.category."io.quarkus.oidc.runtime.OidcProviderClient".level=TRACE quarkus.http.auth.permission.logout.paths=/code-flow/logout quarkus.http.auth.permission.logout.policy=authenticated +quarkus.http.auth.permission.front-channel-logout.paths=/code-flow-form-post/front-channel-logout +quarkus.http.auth.permission.front-channel-logout.policy=authenticated + quarkus.http.auth.permission.backchannellogout.paths=/back-channel-logout quarkus.http.auth.permission.backchannellogout.policy=permit diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java index 9bba7529a88ec..9b574aa049a34 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/CodeFlowAuthorizationTest.java @@ -12,6 +12,7 @@ import java.io.IOException; import java.net.URI; +import java.net.URL; import java.util.Set; import org.junit.jupiter.api.Test; @@ -26,6 +27,7 @@ import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; +import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.runtime.OidcUtils; import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.junit.QuarkusTest; @@ -97,8 +99,7 @@ private void doTestCodeFlowEncryptedIdToken(String tenant) throws IOException { } } - @Test - public void testCodeFlowFormPost() throws IOException { + public void testCodeFlowFormPostAndBackChannelLogout() throws IOException { defineCodeFlowLogoutStub(); try (final WebClient webClient = createWebClient()) { webClient.getOptions().setRedirectEnabled(true); @@ -139,6 +140,56 @@ public void testCodeFlowFormPost() throws IOException { } } + @Test + public void testCodeFlowFormPostAndFrontChannelLogout() throws IOException { + defineCodeFlowLogoutStub(); + try (final WebClient webClient = createWebClient()) { + webClient.getOptions().setRedirectEnabled(true); + HtmlPage page = webClient.getPage("http://localhost:8081/code-flow-form-post"); + + HtmlForm form = page.getFormByName("form"); + form.getInputByName("username").type("alice"); + form.getInputByName("password").type("alice"); + + page = form.getInputByValue("login").click(); + + assertEquals("alice", page.getBody().asText()); + + assertNotNull(getSessionCookie(webClient, "code-flow-form-post")); + + page = webClient.getPage("http://localhost:8081/code-flow-form-post"); + assertEquals("alice", page.getBody().asText()); + + // Session is still active + Cookie sessionCookie = getSessionCookie(webClient, "code-flow-form-post"); + assertNotNull(sessionCookie); + JsonObject idTokenClaims = OidcUtils.decodeJwtContent(sessionCookie.getValue().split("\\|")[0]); + + webClient.getOptions().setRedirectEnabled(false); + + // Confirm 302 is returned and the session cookie is null when the frontchannel logout URL is called + URL frontchannelUrl = URI.create("http://localhost:8081/code-flow-form-post/front-channel-logout" + + "?sid=" + idTokenClaims.getString("sid") + "&iss=" + + OidcCommonUtils.urlEncode(idTokenClaims.getString("iss"))).toURL(); + WebResponse webResponse = webClient.loadWebResponse(new WebRequest(frontchannelUrl)); + assertEquals(302, webResponse.getStatusCode()); + + assertNull(getSessionCookie(webClient, "code-flow-form-post")); + + // remove the state cookie for Quarkus not to treat the next call as an expected redirect from OIDC + webClient.getCookieManager().clearCookies(); + + // Confirm 302 is returned and the session cookie is null when the endpoint is called + webResponse = webClient + .loadWebResponse(new WebRequest(URI.create("http://localhost:8081/code-flow-form-post").toURL())); + assertEquals(302, webResponse.getStatusCode()); + + assertNull(getSessionCookie(webClient, "code-flow-form-post")); + + webClient.getCookieManager().clearCookies(); + } + } + @Test public void testCodeFlowUserInfo() throws IOException { defineCodeFlowAuthorizationOauth2TokenStub(); diff --git a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java index 0678b90538cd3..88bc2ec98a50a 100644 --- a/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java +++ b/test-framework/oidc-server/src/main/java/io/quarkus/test/oidc/server/OidcWiremockTestResource.java @@ -329,6 +329,7 @@ public static String generateJwtToken(String userName, Set groups) { .groups(groups) .issuer(TOKEN_ISSUER) .audience(TOKEN_AUDIENCE) + .claim("sid", "session-id") .subject("123456") .jws() .keyId("1") @@ -340,6 +341,7 @@ public static String getLogoutToken() { .audience(TOKEN_AUDIENCE) .subject("123456") .claim("events", createEventsClaim()) + .claim("sid", "session-id") .jws() .keyId("1") .sign("privateKey.jwk");