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 d94ac01fb431f..e5dfee191cc61 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 @@ -60,6 +60,11 @@ public class OidcTenantConfig { @ConfigItem public Optional jwksPath = Optional.empty(); + /** + * Relative path of the OIDC end_session_endpoint. + */ + @ConfigItem + public Optional endSessionPath = Optional.empty(); /** * Public key for the local JWT token verification. */ @@ -107,6 +112,12 @@ public class OidcTenantConfig { @ConfigItem public Tls tls = new Tls(); + /** + * Logout configuration + */ + @ConfigItem + public Logout logout = new Logout(); + @ConfigGroup public static class Tls { public enum Verification { @@ -137,6 +148,41 @@ public void setVerification(Verification verification) { } + @ConfigGroup + public static class Logout { + + /** + * The relative path of the logout endpoint at the application. If provided, the application is able to initiate the + * logout through this endpoint in conformance with the OpenID Connect RP-Initiated Logout specification. + */ + @ConfigItem + public Optional path = Optional.empty(); + + /** + * Relative path of the application endpoint where the user should be redirected to after logging out from the OpenID + * Connect Provider. + * This endpoint URI must be properly registered at the OpenID Connect Provider as a valid redirect URI. + */ + @ConfigItem + public Optional postLogoutPath = Optional.empty(); + + public void setPath(Optional path) { + this.path = path; + } + + public String getPath() { + return path.get(); + } + + public void setPostLogoutPath(Optional postLogoutPath) { + this.postLogoutPath = postLogoutPath; + } + + public Optional getPostLogoutPath() { + return postLogoutPath; + } + } + public Optional getConnectionDelay() { return connectionDelay; } @@ -169,6 +215,14 @@ public void setJwksPath(String jwksPath) { this.jwksPath = Optional.of(jwksPath); } + public Optional getEndSessionPath() { + return endSessionPath; + } + + public void setEndSessionPath(String endSessionPath) { + this.endSessionPath = Optional.of(endSessionPath); + } + public Optional getPublicKey() { return publicKey; } @@ -233,6 +287,14 @@ public void setProxy(Proxy proxy) { this.proxy = proxy; } + public void setLogout(Logout logout) { + this.logout = logout; + } + + public Logout getLogout() { + return logout; + } + @ConfigGroup public static class Credentials { @@ -540,6 +602,17 @@ public static Token fromAudience(String... audience) { @ConfigItem public Optional principalClaim = Optional.empty(); + /** + * Refresh expired ID tokens. + * If this property is enabled then a refresh token request is performed and, if successful, the local session is + * updated with the new set of tokens. + * Otherwise, the local session is invalidated as an indication that the session at the OpenID Provider no longer + * exists. + * This option is only valid when the application is of type {@link ApplicationType#WEB_APP}}. + */ + @ConfigItem + public boolean refreshExpired; + public Optional getIssuer() { return issuer; } @@ -571,6 +644,14 @@ public Optional getPrincipalClaim() { public void setPrincipalClaim(String principalClaim) { this.principalClaim = Optional.of(principalClaim); } + + public boolean isRefreshExpired() { + return refreshExpired; + } + + public void setRefreshExpired(boolean refreshExpired) { + this.refreshExpired = refreshExpired; + } } @ConfigGroup 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 5049667be722e..b3626f8a981ba 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 @@ -24,6 +24,7 @@ import io.quarkus.oidc.OidcTenantConfig.Credentials; import io.quarkus.oidc.OidcTenantConfig.Credentials.Secret; import io.quarkus.oidc.RefreshToken; +import io.quarkus.security.AuthenticationFailedException; import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.runtime.QuarkusSecurityIdentity; @@ -33,12 +34,16 @@ import io.smallrye.jwt.build.Jwt; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.subscription.UniEmitter; +import io.vertx.core.AsyncResult; +import io.vertx.core.Handler; import io.vertx.core.http.Cookie; import io.vertx.core.http.HttpHeaders; import io.vertx.core.http.impl.ServerCookie; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; import io.vertx.ext.auth.oauth2.AccessToken; +import io.vertx.ext.auth.oauth2.impl.OAuth2AuthProviderImpl; +import io.vertx.ext.auth.oauth2.impl.OAuth2TokenImpl; import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.impl.CookieImpl; @@ -54,10 +59,11 @@ private static QuarkusSecurityIdentity augmentIdentity(SecurityIdentity security String accessToken, String refreshToken, RoutingContext context) { - final RefreshToken refreshTokenCredential = new RefreshToken(refreshToken); + IdTokenCredential idTokenCredential = securityIdentity.getCredential(IdTokenCredential.class); + RefreshToken refreshTokenCredential = new RefreshToken(refreshToken); return QuarkusSecurityIdentity.builder() .setPrincipal(securityIdentity.getPrincipal()) - .addCredentials(securityIdentity.getCredentials()) + .addCredential(idTokenCredential) .addCredential(new AccessTokenCredential(accessToken, refreshTokenCredential, context)) .addCredential(refreshTokenCredential) .addRoles(securityIdentity.getRoles()) @@ -67,23 +73,59 @@ private static QuarkusSecurityIdentity augmentIdentity(SecurityIdentity security public Uni apply(Permission permission) { return securityIdentity.checkPermission(permission); } - }) - .build(); + }).build(); } public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager, DefaultTenantConfigResolver resolver) { Cookie sessionCookie = context.request().getCookie(SESSION_COOKIE_NAME); + TenantConfigContext configContext = resolver.resolve(context, true); // if session already established, try to re-authenticate if (sessionCookie != null) { String[] tokens = sessionCookie.getValue().split(COOKIE_DELIM); + String idToken = tokens[0]; + String accessToken = tokens[1]; + String refreshToken = tokens[2]; + return authenticate(identityProviderManager, new IdTokenCredential(tokens[0], context)) .map(new Function() { @Override - public SecurityIdentity apply(SecurityIdentity securityIdentity) { - return augmentIdentity(securityIdentity, tokens[1], tokens[2], context); + public SecurityIdentity apply(SecurityIdentity identity) { + if (isLogout(context, configContext)) { + throw redirectToLogoutEndpoint(context, configContext, idToken); + } + + return augmentIdentity(identity, accessToken, refreshToken, context); + } + }).on().failure().recoverWithItem(new Function() { + @Override + public SecurityIdentity apply(Throwable throwable) { + if (throwable instanceof AuthenticationRedirectException) { + throw AuthenticationRedirectException.class.cast(throwable); + } + + Throwable cause = throwable.getCause(); + + // we should have proper exception hierarchy to represent token expiration errors + if (cause != null && !cause.getMessage().equalsIgnoreCase("expired token")) { + throw new AuthenticationCompletionException(throwable); + } + + // try silent refresh if required + SecurityIdentity identity = null; + + if (configContext.oidcConfig.token.refreshExpired) { + identity = trySilentRefresh(configContext, idToken, refreshToken, context, + identityProviderManager); + } + + if (identity == null) { + throw new AuthenticationFailedException(throwable); + } + + return identity; } }); } @@ -106,14 +148,13 @@ public Uni getChallenge(RoutingContext context, DefaultTenantConf params.put("scopes", new JsonArray(scopes)); // redirect_uri - URI absoluteUri = URI.create(context.request().absoluteURI()); - String redirectPath = getRedirectPath(configContext, absoluteUri); - String redirectUriParam = buildRedirectUri(context, absoluteUri, redirectPath); + String redirectPath = getRedirectPath(configContext, context); + String redirectUriParam = buildUri(context, redirectPath); LOG.debugf("Authentication request redirect_uri parameter: %s", redirectUriParam); params.put("redirect_uri", redirectUriParam); // state - params.put("state", generateState(context, configContext, absoluteUri, redirectPath)); + params.put("state", generateState(context, configContext, redirectPath)); // extra redirect parameters, see https://openid.net/specs/openid-connect-core-1_0.html#AuthRequests if (configContext.oidcConfig.authentication.getExtraParams() != null) { @@ -139,8 +180,6 @@ private Uni performCodeFlow(IdentityProviderManager identityPr return Uni.createFrom().optional(Optional.empty()); } - URI absoluteUri = URI.create(context.request().absoluteURI()); - Cookie stateCookie = context.getCookie(STATE_COOKIE_NAME); if (stateCookie != null) { List values = context.queryParam("state"); @@ -162,11 +201,11 @@ private Uni performCodeFlow(IdentityProviderManager identityPr String extraQuery = "?pathChecked=true"; // The query parameters returned from IDP need to be included - if (absoluteUri.getRawQuery() != null) { - extraQuery += ("&" + absoluteUri.getRawQuery()); + if (context.request().query() != null) { + extraQuery += ("&" + context.request().query()); } - String localRedirectUri = buildRedirectUri(context, absoluteUri, extraPath + extraQuery); + String localRedirectUri = buildUri(context, extraPath + extraQuery); LOG.debugf("Local redirect URI: %s", localRedirectUri); return Uni.createFrom().failure(new AuthenticationRedirectException(localRedirectUri)); } @@ -187,8 +226,8 @@ private Uni performCodeFlow(IdentityProviderManager identityPr params.put("code", code); // 'redirect_uri': typically it must match the 'redirect_uri' query parameter which was used during the code request. - String redirectPath = getRedirectPath(configContext, absoluteUri); - String redirectUriParam = buildRedirectUri(context, absoluteUri, redirectPath); + String redirectPath = getRedirectPath(configContext, context); + String redirectUriParam = buildUri(context, redirectPath); LOG.debugf("Token request redirect_uri parameter: %s", redirectUriParam); params.put("redirect_uri", redirectUriParam); @@ -216,13 +255,14 @@ public void accept(UniEmitter uniEmitter) { authenticate(identityProviderManager, new IdTokenCredential(result.opaqueIdToken(), context)) .subscribe().with(new Consumer() { @Override - public void accept(SecurityIdentity securityIdentity) { + public void accept(SecurityIdentity identity) { if (!result.idToken().containsKey("exp") || !result.idToken().containsKey("iat")) { LOG.debug("ID Token is required to contain 'exp' and 'iat' claims"); uniEmitter.fail(new AuthenticationCompletionException()); } - processSuccessfulAuthentication(context, configContext, uniEmitter, result, - securityIdentity); + processSuccessfulAuthentication(context, configContext, result, identity); + uniEmitter.complete(augmentIdentity(identity, result.opaqueAccessToken(), + result.opaqueRefreshToken(), context)); } }, new Consumer() { @Override @@ -254,7 +294,6 @@ private String signJwtWithClientSecret(OidcTenantConfig cfg) { } private void processSuccessfulAuthentication(RoutingContext context, TenantConfigContext configContext, - UniEmitter cf, AccessToken result, SecurityIdentity securityIdentity) { removeCookie(context, configContext, SESSION_COOKIE_NAME); @@ -275,24 +314,21 @@ private void processSuccessfulAuthentication(RoutingContext context, TenantConfi cookie.setPath(configContext.oidcConfig.authentication.cookiePath.get()); } context.response().addCookie(cookie); - - cf.complete(augmentIdentity(securityIdentity, result.opaqueAccessToken(), - result.opaqueRefreshToken(), context)); } - private String getRedirectPath(TenantConfigContext configContext, URI absoluteUri) { + private String getRedirectPath(TenantConfigContext configContext, RoutingContext context) { Authentication auth = configContext.oidcConfig.getAuthentication(); - return auth.getRedirectPath().isPresent() ? auth.getRedirectPath().get() : absoluteUri.getRawPath(); + return auth.getRedirectPath().isPresent() ? auth.getRedirectPath().get() : context.request().path(); } - private String generateState(RoutingContext context, TenantConfigContext configContext, URI absoluteUri, + private String generateState(RoutingContext context, TenantConfigContext configContext, String redirectPath) { String uuid = UUID.randomUUID().toString(); String cookieValue = uuid; Authentication auth = configContext.oidcConfig.getAuthentication(); - if (auth.isRestorePathAfterRedirect() && !redirectPath.equals(absoluteUri.getRawPath())) { - cookieValue += (COOKIE_DELIM + absoluteUri.getRawPath()); + if (auth.isRestorePathAfterRedirect() && !redirectPath.equals(context.request().path())) { + cookieValue += (COOKIE_DELIM + context.request().path()); } CookieImpl cookie = new CookieImpl(STATE_COOKIE_NAME, cookieValue); @@ -308,9 +344,9 @@ private String generateState(RoutingContext context, TenantConfigContext configC return uuid; } - private String buildRedirectUri(RoutingContext context, URI absoluteUri, String path) { + private String buildUri(RoutingContext context, String path) { return new StringBuilder(context.request().scheme()).append("://") - .append(absoluteUri.getAuthority()) + .append(URI.create(context.request().absoluteURI()).getAuthority()) .append(path) .toString(); } @@ -326,4 +362,80 @@ private void removeCookie(RoutingContext context, TenantConfigContext configCont } } } + + private boolean isLogout(RoutingContext context, TenantConfigContext configContext) { + Optional logoutPath = configContext.oidcConfig.logout.path; + + if (logoutPath.isPresent()) { + return context.request().absoluteURI().equals( + buildUri(context, logoutPath.get())); + } + + return false; + } + + private SecurityIdentity trySilentRefresh(TenantConfigContext configContext, String idToken, String refreshToken, + RoutingContext context, IdentityProviderManager identityProviderManager) { + + Uni cf = Uni.createFrom().emitter(new Consumer>() { + @Override + public void accept(UniEmitter emitter) { + OAuth2TokenImpl token = new OAuth2TokenImpl(configContext.auth, new JsonObject()); + + // always get the last token + token.principal().put("refresh_token", refreshToken); + + token.refresh(new Handler>() { + @Override + public void handle(AsyncResult result) { + if (result.succeeded()) { + authenticate(identityProviderManager, + new IdTokenCredential(token.opaqueIdToken(), context)) + .subscribe().with(new Consumer() { + @Override + public void accept(SecurityIdentity identity) { + // after a successful refresh, rebuild the identity and update the cookie + processSuccessfulAuthentication(context, configContext, token, + identity); + // update the token so that blocking threads get the latest one + emitter.complete( + augmentIdentity(identity, token.opaqueAccessToken(), + token.opaqueRefreshToken(), + context)); + } + }, new Consumer() { + @Override + public void accept(Throwable throwable) { + emitter.fail(throwable); + } + }); + } else { + emitter.fail(new AuthenticationFailedException(result.cause())); + } + } + }); + } + }); + + return cf.await().indefinitely(); + } + + private String buildLogoutRedirectUri(TenantConfigContext configContext, String idToken, RoutingContext context) { + String logoutPath = configContext.oidcConfig.getEndSessionPath() + .orElse(OAuth2AuthProviderImpl.class.cast(configContext.auth).getConfig().getLogoutPath()); + StringBuilder logoutUri = new StringBuilder(logoutPath).append("?").append("id_token_hint=").append(idToken); + + if (configContext.oidcConfig.logout.postLogoutPath.isPresent()) { + logoutUri.append("&post_logout_redirect_uri=").append( + buildUri(context, configContext.oidcConfig.logout.postLogoutPath.get())); + } + + return logoutUri.toString(); + } + + private AuthenticationRedirectException redirectToLogoutEndpoint(RoutingContext context, TenantConfigContext configContext, + String idToken) { + removeCookie(context, configContext, SESSION_COOKIE_NAME); + return new AuthenticationRedirectException(buildLogoutRedirectUri(configContext, idToken, context)); + } } diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java index e4489ccb4cf36..770fe7a4ae7ea 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/DefaultTenantConfigResolver.java @@ -82,7 +82,14 @@ private TenantConfigContext getTenantConfigFromTenantResolver(RoutingContext con } boolean isBlocking(RoutingContext context) { - return getTenantConfigFromConfigResolver(context, false) == null; + TenantConfigContext resolver = getTenantConfigFromConfigResolver(context, false); + + if (resolver != null) { + // we always run blocking if refresh token is enabled even if the tenant was already resolved + return resolver.oidcConfig.token.refreshExpired; + } + + return resolver == null; } private TenantConfigContext getTenantConfigFromConfigResolver(RoutingContext context, boolean create) { diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java index 31d2df3b4ce3d..01ce0d4506319 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcIdentityProvider.java @@ -1,25 +1,18 @@ package io.quarkus.oidc.runtime; +import static io.quarkus.oidc.runtime.OidcUtils.validateAndCreateIdentity; + import java.util.function.Consumer; import java.util.function.Supplier; import javax.enterprise.context.ApplicationScoped; import javax.inject.Inject; -import org.eclipse.microprofile.jwt.Claims; -import org.eclipse.microprofile.jwt.JsonWebToken; -import org.jose4j.jwt.JwtClaims; -import org.jose4j.jwt.consumer.InvalidJwtException; - -import io.quarkus.oidc.OIDCException; -import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.security.AuthenticationFailedException; -import io.quarkus.security.ForbiddenException; import io.quarkus.security.identity.AuthenticationRequestContext; import io.quarkus.security.identity.IdentityProvider; import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.request.TokenAuthenticationRequest; -import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.smallrye.mutiny.Uni; import io.smallrye.mutiny.subscription.UniEmitter; import io.vertx.core.AsyncResult; @@ -92,7 +85,8 @@ public void handle(AsyncResult event) { JsonObject tokenJson = event.result().accessToken(); try { uniEmitter.complete( - validateAndCreateIdentity(request, resolvedContext.oidcConfig, tokenJson)); + validateAndCreateIdentity(request.getToken(), resolvedContext.oidcConfig, + tokenJson)); } catch (Throwable ex) { uniEmitter.fail(ex); } @@ -116,43 +110,11 @@ private Uni validateTokenWithoutOidcServer(TokenAuthentication return Uni.createFrom().failure(new AuthenticationFailedException()); } else { try { - return Uni.createFrom().item(validateAndCreateIdentity(request, resolvedContext.oidcConfig, tokenJson)); + return Uni.createFrom() + .item(validateAndCreateIdentity(request.getToken(), resolvedContext.oidcConfig, tokenJson)); } catch (Throwable ex) { return Uni.createFrom().failure(ex); } } } - - private QuarkusSecurityIdentity validateAndCreateIdentity(TokenAuthenticationRequest request, - OidcTenantConfig config, JsonObject tokenJson) - throws Exception { - try { - OidcUtils.validateClaims(config.getToken(), tokenJson); - } catch (OIDCException e) { - throw new AuthenticationFailedException(e); - } - - QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); - builder.addCredential(request.getToken()); - - JsonWebToken jwtPrincipal; - try { - JwtClaims jwtClaims = JwtClaims.parse(tokenJson.encode()); - jwtClaims.setClaim(Claims.raw_token.name(), request.getToken().getToken()); - jwtPrincipal = new OidcJwtCallerPrincipal(jwtClaims, request.getToken(), - config.token.principalClaim.isPresent() ? config.token.principalClaim.get() : null); - } catch (InvalidJwtException e) { - throw new AuthenticationFailedException(e); - } - builder.setPrincipal(jwtPrincipal); - try { - String clientId = config.getClientId().isPresent() ? config.getClientId().get() : null; - for (String role : OidcUtils.findRoles(clientId, config.getRoles(), tokenJson)) { - builder.addRole(role); - } - } catch (Exception e) { - throw new ForbiddenException(e); - } - return builder.build(); - } } 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 e1513576c88b2..509a7ef1ecdac 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 @@ -166,6 +166,29 @@ public void handle(AsyncResult event) { }); auth = cf.join(); + + if (!ApplicationType.WEB_APP.equals(oidcConfig.applicationType)) { + if (oidcConfig.token.refreshExpired) { + throw new RuntimeException( + "The 'token.refresh-expired' property can only be enabled for " + ApplicationType.WEB_APP + + " application types"); + } + if (oidcConfig.logout.path.isPresent()) { + throw new RuntimeException( + "The 'logout.path' property can only be enabled for " + ApplicationType.WEB_APP + + " application types"); + } + } + + String endSessionEndpoint = OAuth2AuthProviderImpl.class.cast(auth).getConfig().getLogoutPath(); + + if (oidcConfig.logout.path.isPresent()) { + if (!oidcConfig.endSessionPath.isPresent() && endSessionEndpoint == null) { + throw new RuntimeException( + "The application supports RP-Initiated Logout but the OpenID Provider does not advertise the end_session_endpoint"); + } + } + break; } catch (Throwable throwable) { while (throwable instanceof CompletionException && throwable.getCause() != null) { 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 8343dffc73b83..322eadb98377f 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 @@ -8,9 +8,16 @@ import java.util.regex.Pattern; import org.eclipse.microprofile.jwt.Claims; +import org.eclipse.microprofile.jwt.JsonWebToken; +import org.jose4j.jwt.JwtClaims; +import org.jose4j.jwt.consumer.InvalidJwtException; import io.quarkus.oidc.OIDCException; import io.quarkus.oidc.OidcTenantConfig; +import io.quarkus.security.AuthenticationFailedException; +import io.quarkus.security.ForbiddenException; +import io.quarkus.security.credential.TokenCredential; +import io.quarkus.security.runtime.QuarkusSecurityIdentity; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; @@ -114,4 +121,36 @@ private static List convertJsonArrayToList(JsonArray claimValue) { } return list; } + + static QuarkusSecurityIdentity validateAndCreateIdentity(TokenCredential credential, + OidcTenantConfig config, JsonObject tokenJson) { + try { + OidcUtils.validateClaims(config.getToken(), tokenJson); + } catch (OIDCException e) { + throw new AuthenticationFailedException(e); + } + + QuarkusSecurityIdentity.Builder builder = QuarkusSecurityIdentity.builder(); + builder.addCredential(credential); + + JsonWebToken jwtPrincipal; + try { + JwtClaims jwtClaims = JwtClaims.parse(tokenJson.encode()); + jwtClaims.setClaim(Claims.raw_token.name(), credential.getToken()); + jwtPrincipal = new OidcJwtCallerPrincipal(jwtClaims, credential, + config.token.principalClaim.isPresent() ? config.token.principalClaim.get() : null); + } catch (InvalidJwtException e) { + throw new AuthenticationFailedException(e); + } + builder.setPrincipal(jwtPrincipal); + try { + String clientId = config.getClientId().isPresent() ? config.getClientId().get() : null; + for (String role : OidcUtils.findRoles(clientId, config.getRoles(), tokenJson)) { + builder.addRole(role); + } + } catch (Exception e) { + throw new ForbiddenException(e); + } + return builder.build(); + } } diff --git a/integration-tests/oidc-code-flow/pom.xml b/integration-tests/oidc-code-flow/pom.xml index a326ece894c6a..4b979773af222 100644 --- a/integration-tests/oidc-code-flow/pom.xml +++ b/integration-tests/oidc-code-flow/pom.xml @@ -57,6 +57,11 @@ htmlunit test + + org.awaitility + awaitility + test + 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 86253f5f5f6b7..c3cf6ce9a13a9 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 @@ -18,6 +18,11 @@ public String resolve(RoutingContext context) { } String path = context.request().path(); + + if (path.contains("tenant-logout")) { + return "tenant-logout"; + } + return path.contains("callback-after-redirect") || path.contains("callback-before-redirect") ? "tenant-1" : path.contains("callback-jwt-after-redirect") || path.contains("callback-jwt-before-redirect") ? "tenant-jwt" : path.contains("callback-jwt-not-used-after-redirect") diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java index 21807d0bca7db..570d4f7f5c495 100644 --- a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/ProtectedResource.java @@ -78,6 +78,12 @@ public String getNameCallbackJwtNotUsedAfterRedirect() { throw new InternalServerErrorException("This method must not be invoked"); } + @GET + @Path("tenant-logout") + public String getTenantLogout() { + return "Tenant Logout"; + } + @GET @Path("access") public String getAccessToken() { diff --git a/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantLogout.java b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantLogout.java new file mode 100644 index 0000000000000..903c8bb87b313 --- /dev/null +++ b/integration-tests/oidc-code-flow/src/main/java/io/quarkus/it/keycloak/TenantLogout.java @@ -0,0 +1,22 @@ +package io.quarkus.it.keycloak; + +import javax.ws.rs.GET; +import javax.ws.rs.Path; + +import io.quarkus.security.Authenticated; + +@Path("/tenant-logout") +public class TenantLogout { + + @Authenticated + @GET + public String getTenantLogout() { + return "Tenant Logout"; + } + + @GET + @Path("post-logout") + public String postLogout() { + return "You were logged out"; + } +} 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 e2a8502631af7..90b67e9f3bb5e 100644 --- a/integration-tests/oidc-code-flow/src/main/resources/application.properties +++ b/integration-tests/oidc-code-flow/src/main/resources/application.properties @@ -63,8 +63,24 @@ quarkus.oidc.tenant-3.authentication.redirect-path=/some/other/path quarkus.oidc.tenant-3.authentication.restore-path-after-redirect=false quarkus.oidc.tenant-3.application-type=web-app +quarkus.oidc.tenant-logout.auth-server-url=${keycloak.url}/realms/logout-realm +quarkus.oidc.tenant-logout.client-id=quarkus-app +quarkus.oidc.tenant-logout.credentials.secret=secret +quarkus.oidc.tenant-logout.application-type=web-app +quarkus.oidc.tenant-logout.authentication.cookie-path=/tenant-logout +quarkus.oidc.tenant-logout.logout.path=/tenant-logout/logout +quarkus.oidc.tenant-logout.logout.post-logout-path=/tenant-logout/post-logout +quarkus.oidc.tenant-logout.token.refresh-expired=true +quarkus.oidc.tenant-logout.token.expiration-grace=120 + quarkus.http.auth.permission.roles1.paths=/index.html quarkus.http.auth.permission.roles1.policy=authenticated +quarkus.http.auth.permission.logout.paths=/tenant-logout +quarkus.http.auth.permission.logout.policy=authenticated + +quarkus.http.auth.permission.post-logout.paths=/tenant-logout/post-logout +quarkus.http.auth.permission.post-logout.policy=permit + quarkus.log.category."io.quarkus.oidc.runtime.CodeAuthenticationMechanism".level=TRACE #quarkus.log.category."org.apache.http".level=TRACE 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 50f9a5aa23b20..ab06548ecf42a 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 @@ -1,5 +1,6 @@ package io.quarkus.it.keycloak; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -8,6 +9,9 @@ import java.io.IOException; import java.net.URI; +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.concurrent.TimeUnit; import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; @@ -111,6 +115,84 @@ public void testTokenTimeoutLogout() throws IOException, InterruptedException { } } + @Test + public void testRPInitiatedLogout() throws IOException { + try (final WebClient webClient = createWebClient()) { + HtmlPage page = webClient.getPage("http://localhost:8081/tenant-logout"); + assertEquals("Log in to logout-realm", page.getTitleText()); + HtmlForm loginForm = page.getForms().get(0); + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + page = loginForm.getInputByName("login").click(); + assertTrue(page.asText().contains("Tenant Logout")); + assertNotNull(getSessionCookie(webClient)); + + page = webClient.getPage("http://localhost:8081/tenant-logout/logout"); + assertTrue(page.asText().contains("You were logged out")); + assertNull(getSessionCookie(webClient)); + + page = webClient.getPage("http://localhost:8081/tenant-logout"); + assertEquals("Log in to logout-realm", page.getTitleText()); + loginForm = page.getForms().get(0); + loginForm.getInputByName("username").setValueAttribute("alice"); + loginForm.getInputByName("password").setValueAttribute("alice"); + page = loginForm.getInputByName("login").click(); + assertTrue(page.asText().contains("Tenant Logout")); + + Cookie sessionCookie = getSessionCookie(webClient); + assertNotNull(sessionCookie); + String idToken = getIdToken(sessionCookie); + + //wait now so that we reach the refresh timeout + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(Duration.ofSeconds(1)) + .until(new Callable() { + @Override + public Boolean call() throws Exception { + webClient.getOptions().setRedirectEnabled(false); + WebResponse webResponse = webClient + .loadWebResponse(new WebRequest(URI.create("http://localhost:8081/tenant-logout").toURL())); + // Should not redirect to OP but silently refresh token + Cookie newSessionCookie = getSessionCookie(webClient); + assertNotNull(newSessionCookie); + return !idToken.equals(getIdToken(newSessionCookie)); + } + }); + + // local session refreshed and still valid + page = webClient.getPage("http://localhost:8081/tenant-logout"); + assertTrue(page.asText().contains("Tenant Logout")); + assertNotNull(getSessionCookie(webClient)); + + //wait now so that we reach the refresh timeout + await().atMost(20, TimeUnit.SECONDS) + .pollInterval(Duration.ofSeconds(1)) + .until(new Callable() { + @Override + public Boolean call() throws Exception { + webClient.getOptions().setRedirectEnabled(false); + WebResponse webResponse = webClient + .loadWebResponse(new WebRequest(URI.create("http://localhost:8081/tenant-logout").toURL())); + // Should redirect to login page given that session is now expired at the OP + int statusCode = webResponse.getStatusCode(); + + if (statusCode == 302) { + assertNull(getSessionCookie(webClient)); + return true; + } + + return false; + } + }); + + // session invalidated because it ended at the OP, should redirect to login page at the OP + webClient.getOptions().setRedirectEnabled(true); + page = webClient.getPage("http://localhost:8081/tenant-logout"); + assertNull(getSessionCookie(webClient)); + assertEquals("Log in to logout-realm", page.getTitleText()); + } + } + @Test public void testIdTokenInjection() throws IOException { try (final WebClient webClient = createWebClient()) { @@ -390,4 +472,8 @@ private String getStateCookieSavedPath(WebClient webClient) { private Cookie getSessionCookie(WebClient webClient) { return webClient.getCookieManager().getCookie("q_session"); } + + private String getIdToken(Cookie sessionCookie) { + return sessionCookie.getValue().split("___")[0]; + } } diff --git a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java index 8e1cf7374c7bd..0567a80f25d3b 100644 --- a/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java +++ b/integration-tests/oidc-code-flow/src/test/java/io/quarkus/it/keycloak/KeycloakRealmResourceManager.java @@ -20,31 +20,39 @@ public class KeycloakRealmResourceManager implements QuarkusTestResourceLifecycleManager { private static final String KEYCLOAK_SERVER_URL = System.getProperty("keycloak.url", "http://localhost:8180/auth"); - private static final String KEYCLOAK_REALM = "quarkus"; + static final String KEYCLOAK_REALM = "quarkus"; private Keycloak keycloak; + private List realms = new ArrayList<>(); @Override public Map start() { + keycloak = createKeycloakClient(); RealmRepresentation realm = createRealm(KEYCLOAK_REALM); + keycloak.realms().create(realm); + realms.add(realm); - realm.getClients().add(createClient("quarkus-app")); - realm.getClients().add(createClientJwt("quarkus-app-jwt")); - realm.getUsers().add(createUser("alice", "user")); - realm.getUsers().add(createUser("admin", "user", "admin")); - realm.getUsers().add(createUser("jdoe", "user", "confidential")); + RealmRepresentation logoutRealm = createRealm("logout-realm"); + // revoke refresh tokens so that they can only be used once + logoutRealm.setRevokeRefreshToken(true); + logoutRealm.setRefreshTokenMaxReuse(0); + logoutRealm.setSsoSessionMaxLifespan(15); + logoutRealm.setAccessTokenLifespan(5); + keycloak.realms().create(logoutRealm); + realms.add(logoutRealm); - keycloak = KeycloakBuilder.builder() + return Collections.emptyMap(); + } + + private static Keycloak createKeycloakClient() { + return KeycloakBuilder.builder() .serverUrl(KEYCLOAK_SERVER_URL) .realm("master") .clientId("admin-cli") .username("admin") .password("admin") .build(); - keycloak.realms().create(realm); - - return Collections.emptyMap(); } private static RealmRepresentation createRealm(String name) { @@ -67,6 +75,12 @@ private static RealmRepresentation createRealm(String name) { realm.getRoles().getRealm().add(new RoleRepresentation("admin", null, false)); realm.getRoles().getRealm().add(new RoleRepresentation("confidential", null, false)); + realm.getClients().add(createClient("quarkus-app")); + realm.getClients().add(createClientJwt("quarkus-app-jwt")); + realm.getUsers().add(createUser("alice", "user")); + realm.getUsers().add(createUser("admin", "user", "admin")); + realm.getUsers().add(createUser("jdoe", "user", "confidential")); + return realm; } @@ -115,6 +129,12 @@ private static UserRepresentation createUser(String username, String... realmRol @Override public void stop() { - keycloak.realm(KEYCLOAK_REALM).remove(); + for (RealmRepresentation realm : realms) { + try { + keycloak.realm(realm.getRealm()).remove(); + } catch (Exception ignore) { + + } + } } }