diff --git a/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc b/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc index cec2db0e39e03..25bab083b97c1 100644 --- a/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-multitenancy.adoc @@ -135,7 +135,7 @@ import io.vertx.ext.web.RoutingContext; @ApplicationScoped public class CustomTenantResolver implements TenantResolver { - @Override + @Override public String resolve(RoutingContext context) { String path = context.request().path(); String[] parts = path.split("/"); @@ -152,6 +152,45 @@ public class CustomTenantResolver implements TenantResolver { From the implementation above, tenants are resolved from the request path so that in case no tenant could be inferred, `null` is returned to indicate that the default tenant configuration should be used. +[NOTE] +=== +When a current tenant represents an OIDC `web-app` application, the current `io.vertx.ext.web.RoutingContext` will contain a `tenant-id` attribute by the time the custom tenant resolver has been called for all the requests completing the code authentication flow and the already authenticated requests, when either a tenant specific state or session cookie already exists. +Therefore, when working with mulltiple OpenId Connect Providers, you only need a path specific check to resolve a tenant id if the `RoutingContext` does not have the `tenant-id` attribute set, for example: + +[source,java] +---- +package org.acme.quickstart.oidc; + +import javax.enterprise.context.ApplicationScoped; + +import io.quarkus.oidc.TenantResolver; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class CustomTenantResolver implements TenantResolver { + + @Override + public String resolve(RoutingContext context) { + String tenantId = context.get("tenant-id"); + if (tenantId != null) { + return tenantId; + } else { + // Initial login request + String path = context.request().path(); + String[] parts = path.split("/"); + + if (parts.length == 0) { + // resolve to default tenant configuration + return null; + } + return parts[1]; + } + } +} +---- + +=== + [NOTE] === If you also use xref:hibernate-orm.adoc#multitenancy[Hibernate ORM multitenancy] and both OIDC and Hibernate ORM tenant IDs are the same and must be extracted from the Vert.x `RoutingContext` then you can pass the tenant id from the OIDC Tenant Resolver to the Hibernate ORM Tenant Resolver as a `RoutingContext` attribute, for example: 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 40fe3217ac77e..314e633565506 100644 --- a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc @@ -474,6 +474,34 @@ quarkus.oidc.logout.post-logout-uri-param=returnTo quarkus.oidc.logout.extra-params.client_id=${quarkus.oidc.client-id} ---- +[[local-logout]] +==== Local Logout + +If you work with a social provider such as Google and are concerned that the users can be logged out from all their Google applications with the <> which redirects the users to the provider's logout endpoint then you can support a local logout with the help of the <> which only clears the local session cookie, for example: + +[source,java] +---- +import javax.inject.Inject; +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import io.quarkus.oidc.OidcSession; + +@Path("/service") +public class ServiceResource { + + @Inject + OidcSession oidcSession; + + @GET + @Path("logout") + public String logout() { + oidcSession.logout().await().indefinitely(); + return "You are logged out". + } +---- + + [[session-management]] === Session Management @@ -495,6 +523,11 @@ You can have this process further optimized by having a simple JavaScript functi Note this user session can not be extended forever - the returning user with the expired ID token will have to re-authenticate at the OIDC provider endpoint once the refresh token has expired. +[[oidc-session]] +==== OidcSession + +`io.quarkus.oidc.OidcSession` is a wrapper around the current `IdToken`. It can help to perform a <>, retrieve the current session's tenant identifier and check when the session will expire. More useful methods will be added to it over time. + ==== TokenStateManager OIDC `CodeAuthenticationMechanism` is using the default `io.quarkus.oidc.TokenStateManager` interface implementation to keep the ID, access and refresh tokens returned in the authorization code or refresh grant responses in a session cookie. It makes Quarkus OIDC endpoints completely stateless. diff --git a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java index 11c4ea79e0a8e..0d5af51006b8e 100644 --- a/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java +++ b/extensions/oidc/deployment/src/main/java/io/quarkus/oidc/deployment/OidcBuildStep.java @@ -36,6 +36,7 @@ import io.quarkus.oidc.runtime.OidcIdentityProvider; import io.quarkus.oidc.runtime.OidcJsonWebTokenProducer; import io.quarkus.oidc.runtime.OidcRecorder; +import io.quarkus.oidc.runtime.OidcSessionImpl; import io.quarkus.oidc.runtime.OidcTokenCredentialProducer; import io.quarkus.oidc.runtime.TenantConfigBean; import io.quarkus.runtime.TlsConfig; @@ -87,7 +88,8 @@ public void additionalBeans(BuildProducer additionalBea .addBeanClass(OidcConfigurationMetadataProducer.class) .addBeanClass(OidcIdentityProvider.class) .addBeanClass(DefaultTenantConfigResolver.class) - .addBeanClass(DefaultTokenStateManager.class); + .addBeanClass(DefaultTokenStateManager.class) + .addBeanClass(OidcSessionImpl.class); additionalBeans.produce(builder.build()); } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcSession.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcSession.java new file mode 100644 index 0000000000000..08ddde5c02551 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/OidcSession.java @@ -0,0 +1,39 @@ +package io.quarkus.oidc; + +import java.time.Instant; + +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.smallrye.mutiny.Uni; + +public interface OidcSession { + + /** + * Return the tenant identifier of the current session + * + * @return tenant id + */ + String getTenantId(); + + /** + * Return an {@linkplain:Instant} indicating how long will it take for the current session to expire. + * + * @return + */ + Instant expiresIn(); + + /** + * Perform a local logout without a redirect to the OpenId Connect provider. + * + * @return Uni + */ + Uni logout(); + + /** + * Return the ID token the current session depends upon. + * + * @return id token + */ + JsonWebToken getIdToken(); + +} diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BearerAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BearerAuthenticationMechanism.java index 02aa2078288ce..78f4f2fe978de 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BearerAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/BearerAuthenticationMechanism.java @@ -1,7 +1,5 @@ package io.quarkus.oidc.runtime; -import java.util.function.Function; - import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpResponseStatus; import io.quarkus.oidc.AccessTokenCredential; @@ -21,18 +19,13 @@ public class BearerAuthenticationMechanism extends AbstractOidcAuthenticationMec HttpHeaderNames.WWW_AUTHENTICATE, OidcConstants.BEARER_SCHEME); public Uni authenticate(RoutingContext context, - IdentityProviderManager identityProviderManager) { - return resolver.resolveConfig(context).chain(new Function>() { - @Override - public Uni apply(OidcTenantConfig oidcTenantConfig) { - String token = extractBearerToken(context, oidcTenantConfig); - // if a bearer token is provided try to authenticate - if (token != null) { - return authenticate(identityProviderManager, context, new AccessTokenCredential(token)); - } - return Uni.createFrom().nullItem(); - } - }); + IdentityProviderManager identityProviderManager, OidcTenantConfig oidcTenantConfig) { + String token = extractBearerToken(context, oidcTenantConfig); + // if a bearer token is provided try to authenticate + if (token != null) { + return authenticate(identityProviderManager, context, new AccessTokenCredential(token)); + } + return Uni.createFrom().nullItem(); } public Uni getChallenge(RoutingContext context) { 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 be4229c25707f..1f8559c0e8fc0 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 @@ -45,58 +45,50 @@ public class CodeAuthenticationMechanism extends AbstractOidcAuthenticationMecha static final String AMP = "&"; static final String EQ = "="; + static final String UNDERSCORE = "_"; static final String COOKIE_DELIM = "|"; static final Pattern COOKIE_PATTERN = Pattern.compile("\\" + COOKIE_DELIM); - static final String SESSION_COOKIE_NAME = "q_session"; static final String SESSION_MAX_AGE_PARAM = "session-max-age"; static final Uni VOID_UNI = Uni.createFrom().voidItem(); static final Integer MAX_COOKIE_VALUE_LENGTH = 4096; private static final Logger LOG = Logger.getLogger(CodeAuthenticationMechanism.class); - private static final String STATE_COOKIE_NAME = "q_auth"; - private static final String POST_LOGOUT_COOKIE_NAME = "q_post_logout"; - private final BlockingTaskRunner createTokenStateRequestContext = new BlockingTaskRunner(); private final BlockingTaskRunner getTokenStateRequestContext = new BlockingTaskRunner(); - private final BlockingTaskRunner deleteTokensRequestContext = new BlockingTaskRunner(); public Uni authenticate(RoutingContext context, - IdentityProviderManager identityProviderManager) { - return resolver.resolveConfig(context).chain(new Function>() { - @Override - public Uni apply(OidcTenantConfig oidcTenantConfig) { - - final Cookie sessionCookie = context.request().getCookie(getSessionCookieName(oidcTenantConfig)); - - // if session already established, try to re-authenticate - if (sessionCookie != null) { - Uni resolvedContext = resolver.resolveContext(context); - return resolvedContext.onItem() - .transformToUni(new Function>() { - @Override - public Uni apply(TenantConfigContext tenantContext) { - return reAuthenticate(sessionCookie, context, identityProviderManager, tenantContext); - } - }); - } + IdentityProviderManager identityProviderManager, OidcTenantConfig oidcTenantConfig) { + final Cookie sessionCookie = context.request().getCookie(getSessionCookieName(oidcTenantConfig)); + + // if session already established, try to re-authenticate + if (sessionCookie != null) { + context.put(OidcUtils.SESSION_COOKIE_NAME, sessionCookie.getName()); + Uni resolvedContext = resolver.resolveContext(context); + return resolvedContext.onItem() + .transformToUni(new Function>() { + @Override + public Uni apply(TenantConfigContext tenantContext) { + return reAuthenticate(sessionCookie, context, identityProviderManager, tenantContext); + } + }); + } - final String code = context.request().getParam("code"); - if (code == null) { - return Uni.createFrom().optional(Optional.empty()); - } + final String code = context.request().getParam("code"); + if (code == null) { + return Uni.createFrom().optional(Optional.empty()); + } + + // start a new session by starting the code flow dance + Uni resolvedContext = resolver.resolveContext(context); + return resolvedContext.onItem() + .transformToUni(new Function>() { + @Override + public Uni apply(TenantConfigContext tenantContext) { + return performCodeFlow(identityProviderManager, context, tenantContext, code); + } + }); - // start a new session by starting the code flow dance - Uni resolvedContext = resolver.resolveContext(context); - return resolvedContext.onItem() - .transformToUni(new Function>() { - @Override - public Uni apply(TenantConfigContext tenantContext) { - return performCodeFlow(identityProviderManager, context, tenantContext, code); - } - }); - } - }); } private Uni reAuthenticate(Cookie sessionCookie, @@ -188,7 +180,7 @@ public Uni apply(TenantConfigContext tenantContext) { } public Uni getChallengeInternal(RoutingContext context, TenantConfigContext configContext) { - return removeSessionCookie(context, configContext, getSessionCookieName(configContext.oidcConfig)) + return removeSessionCookie(context, configContext.oidcConfig) .chain(new Function>() { @Override @@ -271,7 +263,7 @@ private Uni performCodeFlow(IdentityProviderManager identityPr userPath = pair[1]; } } - removeCookie(context, configContext, getStateCookieName(configContext)); + OidcUtils.removeCookie(context, configContext.oidcConfig, stateCookie.getName()); } } else { // State cookie must be available to minimize the risk of CSRF @@ -371,7 +363,7 @@ private Uni processSuccessfulAuthentication(RoutingContext context, TenantConfigContext configContext, AuthorizationCodeTokens tokens, SecurityIdentity securityIdentity) { - return removeSessionCookie(context, configContext, getSessionCookieName(configContext.oidcConfig)) + return removeSessionCookie(context, configContext.oidcConfig) .chain(new Function>() { @Override @@ -458,7 +450,7 @@ private String generateCodeFlowState(RoutingContext context, TenantConfigContext } private String generatePostLogoutState(RoutingContext context, TenantConfigContext configContext) { - removeCookie(context, configContext, getPostLogoutCookieName(configContext)); + OidcUtils.removeCookie(context, configContext.oidcConfig, getPostLogoutCookieName(configContext)); return createCookie(context, configContext.oidcConfig, getPostLogoutCookieName(configContext), UUID.randomUUID().toString(), 60 * 30).getValue(); @@ -472,7 +464,7 @@ static ServerCookie createCookie(RoutingContext context, OidcTenantConfig oidcCo cookie.setMaxAge(maxAge); LOG.debugf(name + " cookie 'max-age' parameter is set to %d", maxAge); Authentication auth = oidcConfig.getAuthentication(); - setCookiePath(context, auth, cookie); + OidcUtils.setCookiePath(context, auth, cookie); if (auth.cookieDomain.isPresent()) { cookie.setDomain(auth.getCookieDomain().get()); } @@ -480,14 +472,6 @@ static ServerCookie createCookie(RoutingContext context, OidcTenantConfig oidcCo return cookie; } - static void setCookiePath(RoutingContext context, Authentication auth, ServerCookie cookie) { - if (auth.cookiePathHeader.isPresent() && context.request().headers().contains(auth.cookiePathHeader.get())) { - cookie.setPath(context.request().getHeader(auth.cookiePathHeader.get())); - } else { - cookie.setPath(auth.getCookiePath()); - } - } - private String buildUri(RoutingContext context, boolean forceHttps, String path) { String authority = URI.create(context.request().absoluteURI()).getAuthority(); return buildUri(context, forceHttps, authority, path); @@ -512,38 +496,6 @@ private String buildUri(RoutingContext context, boolean forceHttps, String autho .toString(); } - private Uni removeSessionCookie(RoutingContext context, TenantConfigContext configContext, String cookieName) { - String cookieValue = removeCookie(context, configContext, cookieName); - if (cookieValue != null) { - return resolver.getTokenStateManager().deleteTokens(context, configContext.oidcConfig, cookieValue, - deleteTokensRequestContext); - } else { - return VOID_UNI; - } - } - - private String removeCookie(RoutingContext context, TenantConfigContext configContext, String cookieName) { - ServerCookie cookie = (ServerCookie) context.cookieMap().get(cookieName); - String cookieValue = null; - if (cookie != null) { - cookieValue = cookie.getValue(); - removeCookie(context, cookie, configContext.oidcConfig); - } - return cookieValue; - } - - static void removeCookie(RoutingContext context, ServerCookie cookie, OidcTenantConfig oidcConfig) { - if (cookie != null) { - cookie.setValue(""); - cookie.setMaxAge(0); - Authentication auth = oidcConfig.getAuthentication(); - setCookiePath(context, auth, cookie); - if (auth.cookieDomain.isPresent()) { - cookie.setDomain(auth.cookieDomain.get()); - } - } - } - private boolean isLogout(RoutingContext context, TenantConfigContext configContext) { Optional logoutPath = configContext.oidcConfig.logout.path; @@ -660,7 +612,7 @@ private boolean isForceHttps(TenantConfigContext configContext) { private Uni buildLogoutRedirectUriUni(RoutingContext context, TenantConfigContext configContext, String idToken) { - return removeSessionCookie(context, configContext, getSessionCookieName(configContext.oidcConfig)) + return removeSessionCookie(context, configContext.oidcConfig) .map(new Function() { @Override public Void apply(Void t) { @@ -670,23 +622,29 @@ public Void apply(Void t) { } private static String getStateCookieName(TenantConfigContext configContext) { - return STATE_COOKIE_NAME + getCookieSuffix(configContext.oidcConfig); + return OidcUtils.STATE_COOKIE_NAME + getCookieSuffix(configContext.oidcConfig); } private static String getPostLogoutCookieName(TenantConfigContext configContext) { - return POST_LOGOUT_COOKIE_NAME + getCookieSuffix(configContext.oidcConfig); + return OidcUtils.POST_LOGOUT_COOKIE_NAME + getCookieSuffix(configContext.oidcConfig); } private static String getSessionCookieName(OidcTenantConfig oidcConfig) { - return SESSION_COOKIE_NAME + getCookieSuffix(oidcConfig); + return OidcUtils.SESSION_COOKIE_NAME + getCookieSuffix(oidcConfig); + } + + private Uni removeSessionCookie(RoutingContext context, OidcTenantConfig oidcConfig) { + String cookieName = getSessionCookieName(oidcConfig); + return OidcUtils.removeSessionCookie(context, oidcConfig, cookieName, resolver.getTokenStateManager()); } static String getCookieSuffix(OidcTenantConfig oidcConfig) { String tenantId = oidcConfig.tenantId.get(); - String tenantIdSuffix = !"Default".equals(tenantId) ? "_" + tenantId : ""; + boolean cookieSuffixConfigured = oidcConfig.authentication.cookieSuffix.isPresent(); + String tenantIdSuffix = (cookieSuffixConfigured || !"Default".equals(tenantId)) ? UNDERSCORE + tenantId : ""; - return oidcConfig.authentication.cookieSuffix.isPresent() - ? (tenantIdSuffix + "_" + oidcConfig.authentication.cookieSuffix.get()) + return cookieSuffixConfigured + ? (tenantIdSuffix + UNDERSCORE + oidcConfig.authentication.cookieSuffix.get()) : tenantIdSuffix; } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenStateManager.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenStateManager.java index dd477dfc5d8c1..13657f406139a 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenStateManager.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTokenStateManager.java @@ -14,8 +14,8 @@ @ApplicationScoped public class DefaultTokenStateManager implements TokenStateManager { - private static final String SESSION_AT_COOKIE_NAME = CodeAuthenticationMechanism.SESSION_COOKIE_NAME + "_at"; - private static final String SESSION_RT_COOKIE_NAME = CodeAuthenticationMechanism.SESSION_COOKIE_NAME + "_rt"; + private static final String SESSION_AT_COOKIE_NAME = OidcUtils.SESSION_COOKIE_NAME + "_at"; + private static final String SESSION_RT_COOKIE_NAME = OidcUtils.SESSION_COOKIE_NAME + "_rt"; @Override public Uni createTokenState(RoutingContext routingContext, OidcTenantConfig oidcConfig, @@ -101,9 +101,9 @@ public Uni getTokens(RoutingContext routingContext, Oid public Uni deleteTokens(RoutingContext routingContext, OidcTenantConfig oidcConfig, String tokenState, OidcRequestContext requestContext) { if (oidcConfig.tokenStateManager.splitTokens) { - CodeAuthenticationMechanism.removeCookie(routingContext, getAccessTokenCookie(routingContext, oidcConfig), + OidcUtils.removeCookie(routingContext, getAccessTokenCookie(routingContext, oidcConfig), oidcConfig); - CodeAuthenticationMechanism.removeCookie(routingContext, getRefreshTokenCookie(routingContext, oidcConfig), + OidcUtils.removeCookie(routingContext, getRefreshTokenCookie(routingContext, oidcConfig), oidcConfig); } return CodeAuthenticationMechanism.VOID_UNI; diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcAuthenticationMechanism.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcAuthenticationMechanism.java index 78e90f34909fe..7ba3da804c859 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcAuthenticationMechanism.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcAuthenticationMechanism.java @@ -39,20 +39,22 @@ public void init() { @Override public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + setTenantIdAttribute(context); return resolve(context).chain(new Function>() { @Override public Uni apply(OidcTenantConfig oidcConfig) { if (!oidcConfig.tenantEnabled) { return Uni.createFrom().nullItem(); } - return isWebApp(context, oidcConfig) ? codeAuth.authenticate(context, identityProviderManager) - : bearerAuth.authenticate(context, identityProviderManager); + return isWebApp(context, oidcConfig) ? codeAuth.authenticate(context, identityProviderManager, oidcConfig) + : bearerAuth.authenticate(context, identityProviderManager, oidcConfig); } }); } @Override public Uni getChallenge(RoutingContext context) { + setTenantIdAttribute(context); return resolve(context).chain(new Function>() { @Override public Uni apply(OidcTenantConfig oidcTenantConfig) { @@ -95,4 +97,27 @@ public HttpCredentialTransport getCredentialTransport() { //if OIDC is present we don't really want another bearer mechanism return new HttpCredentialTransport(HttpCredentialTransport.Type.AUTHORIZATION, OidcConstants.BEARER_SCHEME); } + + private static void setTenantIdAttribute(RoutingContext context) { + for (String cookieName : context.cookieMap().keySet()) { + if (cookieName.startsWith(OidcUtils.SESSION_COOKIE_NAME)) { + setTenantIdAttribute(context, OidcUtils.SESSION_COOKIE_NAME, cookieName); + } else if (cookieName.startsWith(OidcUtils.STATE_COOKIE_NAME)) { + setTenantIdAttribute(context, OidcUtils.STATE_COOKIE_NAME, cookieName); + } + } + } + + private static void setTenantIdAttribute(RoutingContext context, String cookiePrefix, String cookieName) { + // It has already been checked the cookieName starts with the cookiePrefix + if (cookieName.length() == cookiePrefix.length()) { + context.put(OidcUtils.TENANT_ID_ATTRIBUTE, OidcUtils.DEFAULT_TENANT_ID); + } else { + String suffix = cookieName.substring(cookiePrefix.length() + 1); + // It can be either a tenant_id or tenand_id and cookie suffix property, example, q_session_github or q_session_github_test + int index = suffix.indexOf("_"); + String tenantId = index == -1 ? suffix : suffix.substring(0, index); + context.put(OidcUtils.TENANT_ID_ATTRIBUTE, tenantId); + } + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java index e84df789458ab..d355640e96083 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcRecorder.java @@ -35,7 +35,6 @@ public class OidcRecorder { private static final Logger LOG = Logger.getLogger(OidcRecorder.class); - private static final String DEFAULT_TENANT_ID = "Default"; private static final Map dynamicTenantsConfig = new ConcurrentHashMap<>(); @@ -46,7 +45,7 @@ public Supplier setupTokenCache(OidcConf public Supplier setup(OidcConfig config, Supplier vertx, TlsConfig tlsConfig) { final Vertx vertxValue = vertx.get(); - String defaultTenantId = config.defaultTenant.getTenantId().orElse(DEFAULT_TENANT_ID); + String defaultTenantId = config.defaultTenant.getTenantId().orElse(OidcUtils.DEFAULT_TENANT_ID); TenantConfigContext defaultTenantContext = createStaticTenantContext(vertxValue, config.defaultTenant, tlsConfig, defaultTenantId); diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcSessionImpl.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcSessionImpl.java new file mode 100644 index 0000000000000..80f897682aa82 --- /dev/null +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcSessionImpl.java @@ -0,0 +1,64 @@ +package io.quarkus.oidc.runtime; + +import java.time.Instant; +import java.util.function.Function; + +import javax.enterprise.context.ApplicationScoped; +import javax.inject.Inject; + +import org.eclipse.microprofile.jwt.JsonWebToken; + +import io.quarkus.oidc.IdToken; +import io.quarkus.oidc.OidcSession; +import io.quarkus.oidc.OidcTenantConfig; +import io.smallrye.mutiny.Uni; +import io.vertx.ext.web.RoutingContext; + +@ApplicationScoped +public class OidcSessionImpl implements OidcSession { + + @Inject + RoutingContext routingContext; + + @Inject + DefaultTenantConfigResolver resolver; + + @Inject + @IdToken + JsonWebToken idToken; + + @Override + public String getTenantId() { + return routingContext.get(OidcUtils.TENANT_ID_ATTRIBUTE); + } + + @Override + public Uni logout() { + String sessionCookieName = routingContext.get(OidcUtils.SESSION_COOKIE_NAME); + if (sessionCookieName != null) { + Uni oidcConfigUni = resolver.resolveConfig(routingContext); + return oidcConfigUni.onItem().transformToUni(new Function>() { + + @Override + public Uni apply(OidcTenantConfig oidcConfig) { + return OidcUtils.removeSessionCookie(routingContext, oidcConfig, sessionCookieName, + resolver.getTokenStateManager()); + } + + }); + } + return Uni.createFrom().voidItem(); + } + + @Override + public Instant expiresIn() { + final long nowSecs = System.currentTimeMillis() / 1000; + return Instant.ofEpochSecond(idToken.getExpirationTime() - nowSecs); + } + + @Override + public JsonWebToken getIdToken() { + return idToken; + } + +} 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 c61086e0e1fe0..f39d8e8488ac6 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 @@ -20,14 +20,18 @@ import io.quarkus.oidc.AuthorizationCodeTokens; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.oidc.OidcTenantConfig.Authentication; import io.quarkus.oidc.RefreshToken; import io.quarkus.oidc.TokenIntrospection; +import io.quarkus.oidc.TokenStateManager; import io.quarkus.oidc.UserInfo; import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.credential.TokenCredential; import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.quarkus.security.runtime.QuarkusSecurityIdentity.Builder; +import io.smallrye.mutiny.Uni; +import io.vertx.core.http.impl.ServerCookie; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.web.RoutingContext; @@ -39,6 +43,13 @@ public final class OidcUtils { public static final String USER_INFO_ATTRIBUTE = "userinfo"; public static final String INTROSPECTION_ATTRIBUTE = "introspection"; public static final String TENANT_ID_ATTRIBUTE = "tenant-id"; + public static final String DEFAULT_TENANT_ID = "Default"; + public static final String SESSION_COOKIE_NAME = "q_session"; + public static final String STATE_COOKIE_NAME = "q_auth"; + public static final String POST_LOGOUT_COOKIE_NAME = "q_post_logout"; + static final Uni VOID_UNI = Uni.createFrom().voidItem(); + static final BlockingTaskRunner deleteTokensRequestContext = new BlockingTaskRunner(); + /** * This pattern uses a positive lookahead to split an expression around the forward slashes * ignoring those which are located inside a pair of the double quotes. @@ -230,4 +241,45 @@ public static void validatePrimaryJwtTokenType(OidcTenantConfig.Token tokenConfi } } } + + static Uni removeSessionCookie(RoutingContext context, OidcTenantConfig oidcConfig, String cookieName, + TokenStateManager tokenStateManager) { + String cookieValue = removeCookie(context, oidcConfig, cookieName); + if (cookieValue != null) { + return tokenStateManager.deleteTokens(context, oidcConfig, cookieValue, + deleteTokensRequestContext); + } else { + return VOID_UNI; + } + } + + static String removeCookie(RoutingContext context, OidcTenantConfig oidcConfig, String cookieName) { + ServerCookie cookie = (ServerCookie) context.cookieMap().get(cookieName); + String cookieValue = null; + if (cookie != null) { + cookieValue = cookie.getValue(); + removeCookie(context, cookie, oidcConfig); + } + return cookieValue; + } + + static void removeCookie(RoutingContext context, ServerCookie cookie, OidcTenantConfig oidcConfig) { + if (cookie != null) { + cookie.setValue(""); + cookie.setMaxAge(0); + Authentication auth = oidcConfig.getAuthentication(); + setCookiePath(context, auth, cookie); + if (auth.cookieDomain.isPresent()) { + cookie.setDomain(auth.cookieDomain.get()); + } + } + } + + static void setCookiePath(RoutingContext context, Authentication auth, ServerCookie cookie) { + if (auth.cookiePathHeader.isPresent() && context.request().headers().contains(auth.cookiePathHeader.get())) { + cookie.setPath(context.request().getHeader(auth.cookiePathHeader.get())); + } else { + cookie.setPath(auth.getCookiePath()); + } + } } 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 648205d098ace..14fe57742edd6 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 @@ -5,6 +5,7 @@ import javax.enterprise.context.ApplicationScoped; import io.quarkus.oidc.TenantResolver; +import io.quarkus.oidc.runtime.OidcUtils; import io.vertx.ext.web.RoutingContext; @ApplicationScoped @@ -52,7 +53,12 @@ public String resolve(RoutingContext context) { } if (path.contains("tenant-https")) { - return "tenant-https"; + if (context.getCookie("q_session_tenant-https_test") != null) { + context.put("reauthenticated", "true"); + return context.get(OidcUtils.TENANT_ID_ATTRIBUTE); + } else { + return "tenant-https"; + } } if (path.contains("tenant-xhr")) { @@ -88,6 +94,6 @@ public String resolve(RoutingContext context) { return "tenant-2"; } - return null; + return OidcUtils.DEFAULT_TENANT_ID; } } diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantHttps.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantHttps.java index d147326e894c5..fb6dee72eff5b 100644 --- a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantHttps.java +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantHttps.java @@ -1,23 +1,31 @@ package io.quarkus.it.keycloak; +import javax.inject.Inject; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.QueryParam; +import io.quarkus.oidc.OidcSession; import io.quarkus.security.Authenticated; +import io.vertx.ext.web.RoutingContext; @Path("/tenant-https") @Authenticated public class TenantHttps { + @Inject + OidcSession session; + @Inject + RoutingContext routingContext; + @GET public String getTenant() { - return "tenant-https"; + return session.getTenantId() + (routingContext.get("reauthenticated") != null ? ":reauthenticated" : ""); } @GET @Path("query") public String getTenantWithQuery(@QueryParam("a") String value) { - return "tenant-https?a=" + value; + return getTenant() + "?a=" + value; } } 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 8d2dc7852b69a..ba799461de52d 100644 --- a/integration-tests/oidc-code-flow/src/main/resources/application.properties +++ b/integration-tests/oidc-code-flow/src/main/resources/application.properties @@ -9,6 +9,7 @@ quarkus.oidc.authentication.cookie-path-header=some-header quarkus.oidc.authentication.cookie-domain=localhost quarkus.oidc.authentication.extra-params.max-age=60 quarkus.oidc.application-type=web-app +quarkus.oidc.authentication.cookie-suffix=test # OIDC client configuration quarkus.oidc-client.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 0aa050f527331..b8eb0507bda2d 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 @@ -78,6 +78,9 @@ public void testCodeFlowNoConsent() throws IOException { assertTrue(page.asText().contains("openid")); assertTrue(page.asText().contains("profile")); + Cookie sessionCookie = getSessionCookie(webClient, null); + assertNotNull(sessionCookie); + webClient.getCookieManager().clearCookies(); } } @@ -130,7 +133,7 @@ public void testCodeFlowForceHttpsRedirectUri() throws IOException { assertNull(endpointLocationWithoutQueryUri.getRawQuery()); page = webClient.getPage(endpointLocationWithoutQueryUri.toURL()); - assertEquals("tenant-https", page.getBody().asText()); + assertEquals("tenant-https:reauthenticated", page.getBody().asText()); Cookie sessionCookie = getSessionCookie(webClient, "tenant-https_test"); assertNotNull(sessionCookie); webClient.getCookieManager().clearCookies(); @@ -178,7 +181,7 @@ public void testCodeFlowForceHttpsRedirectUriWithQuery() throws IOException { assertEquals("a=b", endpointLocationWithoutQueryUri.getRawQuery()); page = webClient.getPage(endpointLocationWithoutQueryUri.toURL()); - assertEquals("tenant-https?a=b", page.getBody().asText()); + assertEquals("tenant-https:reauthenticated?a=b", page.getBody().asText()); Cookie sessionCookie = getSessionCookie(webClient, "tenant-https_test"); assertNotNull(sessionCookie); webClient.getCookieManager().clearCookies(); @@ -941,7 +944,7 @@ private WebClient createWebClient() { } private Cookie getStateCookie(WebClient webClient, String tenantId) { - return webClient.getCookieManager().getCookie("q_auth" + (tenantId == null ? "" : "_" + tenantId)); + return webClient.getCookieManager().getCookie("q_auth" + (tenantId == null ? "_Default_test" : "_" + tenantId)); } private String getStateCookieStateParam(WebClient webClient, String tenantId) { @@ -954,15 +957,15 @@ private String getStateCookieSavedPath(WebClient webClient, String tenantId) { } private Cookie getSessionCookie(WebClient webClient, String tenantId) { - return webClient.getCookieManager().getCookie("q_session" + (tenantId == null ? "" : "_" + tenantId)); + return webClient.getCookieManager().getCookie("q_session" + (tenantId == null ? "_Default_test" : "_" + tenantId)); } private Cookie getSessionAtCookie(WebClient webClient, String tenantId) { - return webClient.getCookieManager().getCookie("q_session_at" + (tenantId == null ? "" : "_" + tenantId)); + return webClient.getCookieManager().getCookie("q_session_at" + (tenantId == null ? "_Default_test" : "_" + tenantId)); } private Cookie getSessionRtCookie(WebClient webClient, String tenantId) { - return webClient.getCookieManager().getCookie("q_session_rt" + (tenantId == null ? "" : "_" + tenantId)); + return webClient.getCookieManager().getCookie("q_session_rt" + (tenantId == null ? "_Default_test" : "_" + tenantId)); } private String getIdToken(Cookie sessionCookie) { diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java index 0d6947562f813..8996e244bf8ae 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/CustomTenantResolver.java @@ -3,6 +3,7 @@ import javax.enterprise.context.ApplicationScoped; import io.quarkus.oidc.TenantResolver; +import io.quarkus.oidc.runtime.OidcUtils; import io.vertx.ext.web.RoutingContext; @ApplicationScoped @@ -23,6 +24,13 @@ public String resolve(RoutingContext context) { if ("tenant-hybrid".equals(tenantId)) { return context.request().getHeader("Authorization") != null ? "tenant-hybrid-service" : "tenant-hybrid-webapp"; } + + if ("tenant-web-app".equals(tenantId) + && context.getCookie("q_session_tenant-web-app") != null) { + context.put("reauthenticated", "true"); + return context.get(OidcUtils.TENANT_ID_ATTRIBUTE); + } + return tenantId; } diff --git a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java index 7a3b0cf337f81..6fffa6c029ccb 100644 --- a/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java +++ b/integration-tests/oidc-tenancy/src/main/java/io/quarkus/it/keycloak/TenantResource.java @@ -5,6 +5,7 @@ import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.PathParam; +import javax.ws.rs.QueryParam; import org.eclipse.microprofile.jwt.Claims; import org.eclipse.microprofile.jwt.JsonWebToken; @@ -13,9 +14,11 @@ import io.quarkus.oidc.AccessTokenCredential; import io.quarkus.oidc.IdToken; import io.quarkus.oidc.OIDCException; +import io.quarkus.oidc.OidcSession; import io.quarkus.oidc.TokenIntrospection; import io.quarkus.oidc.UserInfo; import io.quarkus.security.identity.SecurityIdentity; +import io.vertx.ext.web.RoutingContext; @Path("/tenant/{tenant}/api/user") public class TenantResource { @@ -36,6 +39,12 @@ public class TenantResource { @IdToken JsonWebToken idToken; + @Inject + OidcSession oidcSession; + + @Inject + RoutingContext routingContext; + @GET @RolesAllowed("user") public String userNameService(@PathParam("tenant") String tenant) { @@ -72,23 +81,41 @@ public String userNameServiceNoDiscovery(@PathParam("tenant") String tenant) { @GET @Path("webapp") @RolesAllowed("user") - public String userNameWebApp(@PathParam("tenant") String tenant) { + public String userNameWebApp(@PathParam("tenant") String tenant, @QueryParam("logout") boolean localLogout) { if (!tenant.equals("tenant-web-app") && !tenant.equals("tenant-web-app-dynamic") && !tenant.equals("tenant-web-app-no-discovery")) { throw new OIDCException("Wrong tenant"); } + if (!tenant.equals(oidcSession.getTenantId())) { + throw new OIDCException("'tenant' parameter does not match the OIDC session tenantid"); + } UserInfo userInfo = getUserInfo(); if (!idToken.getGroups().contains("user")) { throw new OIDCException("Groups expected"); } - return tenant + ":" + getNameWebAppType(userInfo.getString("upn"), "upn", "preferred_username"); + + if (!idToken.getRawToken().equals(oidcSession.getIdToken().getRawToken())) { + throw new OIDCException("Wrong ID token injection"); + } + + String response = tenant + ":" + getNameWebAppType(userInfo.getString("upn"), "upn", "preferred_username"); + + if (routingContext.get("reauthenticated") != null) { + response += ":reauthenticated"; + } + + if (localLogout) { + oidcSession.logout().await().indefinitely(); + response += ":logout"; + } + return response; } @GET @Path("webapp-no-discovery") @RolesAllowed("user") public String userNameWebAppNoDiscovery(@PathParam("tenant") String tenant) { - return userNameWebApp(tenant); + return userNameWebApp(tenant, false); } private UserInfo getUserInfo() { diff --git a/integration-tests/oidc-tenancy/src/main/resources/application.properties b/integration-tests/oidc-tenancy/src/main/resources/application.properties index b5b68a95062fe..8d61c8986a6b2 100644 --- a/integration-tests/oidc-tenancy/src/main/resources/application.properties +++ b/integration-tests/oidc-tenancy/src/main/resources/application.properties @@ -101,4 +101,4 @@ quarkus.oidc.tenant-public-key.public-key=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCg smallrye.jwt.sign.key.location=/privateKey.pem quarkus.log.category."com.gargoylesoftware.htmlunit.javascript.host.css.CSSStyleSheet".level=FATAL -quarkus.http.auth.proactive=false \ No newline at end of file +quarkus.http.auth.proactive=false diff --git a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java index a18de4bab3635..db0d935df6a30 100644 --- a/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java +++ b/integration-tests/oidc-tenancy/src/test/java/io/quarkus/it/keycloak/BearerTokenAuthorizationTest.java @@ -7,12 +7,15 @@ import static org.junit.jupiter.api.Assertions.assertNull; import java.io.IOException; +import java.net.URI; import org.junit.jupiter.api.Test; import org.keycloak.representations.AccessTokenResponse; import com.gargoylesoftware.htmlunit.SilentCssErrorHandler; import com.gargoylesoftware.htmlunit.WebClient; +import com.gargoylesoftware.htmlunit.WebRequest; +import com.gargoylesoftware.htmlunit.WebResponse; import com.gargoylesoftware.htmlunit.html.HtmlForm; import com.gargoylesoftware.htmlunit.html.HtmlPage; import com.gargoylesoftware.htmlunit.util.Cookie; @@ -38,13 +41,43 @@ public void testResolveTenantIdentifierWebApp() throws IOException { HtmlPage page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app/api/user/webapp"); // State cookie is available but there must be no saved path parameter // as the tenant-web-app configuration does not set a redirect-path property + assertNull(getSessionCookie(webClient, "tenant-web-app")); + assertNotNull(getStateCookie(webClient, "tenant-web-app")); assertNull(getStateCookieSavedPath(webClient, "tenant-web-app")); assertEquals("Sign in to quarkus-webapp", page.getTitleText()); HtmlForm loginForm = page.getForms().get(0); loginForm.getInputByName("username").setValueAttribute("alice"); loginForm.getInputByName("password").setValueAttribute("alice"); page = loginForm.getInputByName("login").click(); - assertEquals("tenant-web-app:alice", page.getBody().asText()); + // First call after a redirect, tenant-id is initially calculated from the state `q_auth` cookie. + // 'reauthenticated' flag is set is because, in fact, it is actually a 2nd call due to + // quarkus-oidc doing a final redirect after completing a code flow to drop the redirect OIDC parameters + assertEquals("tenant-web-app:alice:reauthenticated", page.getBody().asText()); + assertNotNull(getSessionCookie(webClient, "tenant-web-app")); + assertNull(getStateCookie(webClient, "tenant-web-app")); + + // Second call after a redirect, tenant-id is calculated from the state `q_session` cookie + page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app/api/user/webapp"); + assertEquals("tenant-web-app:alice:reauthenticated", page.getBody().asText()); + assertNotNull(getSessionCookie(webClient, "tenant-web-app")); + assertNull(getStateCookie(webClient, "tenant-web-app")); + + // Local logout + page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app/api/user/webapp?logout=true"); + assertEquals("tenant-web-app:alice:reauthenticated:logout", page.getBody().asText()); + assertNull(getSessionCookie(webClient, "tenant-web-app")); + assertNull(getStateCookie(webClient, "tenant-web-app")); + + // Check a new login is requested via redirect + webClient.getOptions().setRedirectEnabled(false); + WebResponse webResponse = webClient + .loadWebResponse( + new WebRequest(URI.create("http://localhost:8081/tenant/tenant-web-app/api/user/webapp").toURL())); + assertEquals(302, webResponse.getStatusCode()); + assertNull(getSessionCookie(webClient, "tenant-web-app")); + assertNotNull(getStateCookie(webClient, "tenant-web-app")); + assertNull(getStateCookieSavedPath(webClient, "tenant-web-app")); + webClient.getCookieManager().clearCookies(); } } @@ -142,7 +175,7 @@ public void testReAuthenticateWhenSwitchingTenants() throws IOException { loginForm.getInputByName("username").setValueAttribute("alice"); loginForm.getInputByName("password").setValueAttribute("alice"); page = loginForm.getInputByName("login").click(); - assertEquals("tenant-web-app:alice", page.getBody().asText()); + assertEquals("tenant-web-app:alice:reauthenticated", page.getBody().asText()); // tenant-web-app2 page = webClient.getPage("http://localhost:8081/tenant/tenant-web-app2/api/user/webapp2"); assertNull(getStateCookieSavedPath(webClient, "tenant-web-app2")); @@ -508,6 +541,10 @@ private Cookie getStateCookie(WebClient webClient, String tenantId) { return webClient.getCookieManager().getCookie("q_auth" + (tenantId == null ? "" : "_" + tenantId)); } + private Cookie getSessionCookie(WebClient webClient, String tenantId) { + return webClient.getCookieManager().getCookie("q_session" + (tenantId == null ? "" : "_" + tenantId)); + } + private String getStateCookieSavedPath(WebClient webClient, String tenantId) { String[] parts = getStateCookie(webClient, tenantId).getValue().split("\\|"); return parts.length == 2 ? parts[1] : null;