diff --git a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc index 8568c72ac20db..3dbd97e8eb429 100644 --- a/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc +++ b/docs/src/main/asciidoc/security-oidc-code-flow-authentication.adoc @@ -1679,6 +1679,15 @@ In such cases, the endpoint might report an issuer verification failure and redi If you work with Keycloak, then start it with a `KEYCLOAK_FRONTEND_URL` system property set to the externally-accessible base URL. If you work with other OIDC providers, check the documentation of your provider. +=== OIDC HTTP client redirects + +OIDC providers behind a firewall may redirect Quarkus OIDC HTTP client's GET requests to some of its endpoints such as a well-known configuration endpoint. +By default, Quarkus OIDC HTTP client follows HTTP redirects automatically, excluding cookies which may have been set during the redirect request for security reasons. + +If you would like, you can disable it with `quarkus.oidc.follow-redirects=false`. + +When following redirects automatically is disabled, and Quarkus OIDC HTTP client receives a redirect request, it will attempt to recover only once by following the redirect URI, but only if it is exactly the same as the original request URI, and as long as one or more cookies were set during the redirect request. + [[oidc-saml-broker]] == OIDC SAML identity broker diff --git a/extensions/oidc-client-registration/runtime/src/main/java/io/quarkus/oidc/client/registration/runtime/OidcClientRegistrationRecorder.java b/extensions/oidc-client-registration/runtime/src/main/java/io/quarkus/oidc/client/registration/runtime/OidcClientRegistrationRecorder.java index 58c4488115f9a..0016d5b585422 100644 --- a/extensions/oidc-client-registration/runtime/src/main/java/io/quarkus/oidc/client/registration/runtime/OidcClientRegistrationRecorder.java +++ b/extensions/oidc-client-registration/runtime/src/main/java/io/quarkus/oidc/client/registration/runtime/OidcClientRegistrationRecorder.java @@ -117,7 +117,7 @@ public static Uni createOidcClientRegistrationUni(OidcCl } WebClientOptions options = new WebClientOptions(); - + options.setFollowRedirects(oidcConfig.followRedirects); OidcCommonUtils.setHttpClientOptions(oidcConfig, options, tlsSupport.forConfig(oidcConfig.tls)); final io.vertx.mutiny.core.Vertx vertx = new io.vertx.mutiny.core.Vertx(vertxSupplier.get()); diff --git a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java index 3955d89010960..7d3f85cd23065 100644 --- a/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java +++ b/extensions/oidc-client/runtime/src/main/java/io/quarkus/oidc/client/runtime/OidcClientRecorder.java @@ -130,7 +130,7 @@ protected static Uni createOidcClientUni(OidcClientConfig oidcConfig } WebClientOptions options = new WebClientOptions(); - + options.setFollowRedirects(oidcConfig.followRedirects); OidcCommonUtils.setHttpClientOptions(oidcConfig, options, tlsSupport.forConfig(oidcConfig.tls)); var mutinyVertx = new io.vertx.mutiny.core.Vertx(vertx.get()); diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientRedirectException.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientRedirectException.java new file mode 100644 index 0000000000000..da892da887751 --- /dev/null +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcClientRedirectException.java @@ -0,0 +1,37 @@ +package io.quarkus.oidc.common.runtime; + +import java.util.ArrayList; +import java.util.List; + +@SuppressWarnings("serial") +public class OidcClientRedirectException extends RuntimeException { + + private final String location; + private final List cookies; + + public OidcClientRedirectException(String location, List setCookies) { + this.location = location; + this.cookies = getCookies(setCookies); + } + + private static List getCookies(List setCookies) { + if (setCookies != null && !setCookies.isEmpty()) { + List cookies = new ArrayList<>(); + for (String setCookie : setCookies) { + int index = setCookie.indexOf(";"); + cookies.add(setCookie.substring(0, index)); + } + return cookies; + } + return List.of(); + } + + public String getLocation() { + return location; + } + + public List getCookies() { + return cookies; + } + +} diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java index 673ea7a8ffee4..9fb2108b6af4e 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonConfig.java @@ -71,6 +71,14 @@ public class OidcCommonConfig { @ConfigItem public OptionalInt maxPoolSize = OptionalInt.empty(); + /** + * Follow redirects automatically when WebClient gets HTTP 302. + * When this property is disabled only a single redirect to exactly the same original URI + * is allowed but only if one or more cookies were set during the redirect request. + */ + @ConfigItem(defaultValue = "true") + public boolean followRedirects = true; + /** * Options to configure the proxy the OIDC adapter uses to talk with the OIDC server. */ diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java index 6766bee4749bf..469e9c57cff54 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcCommonUtils.java @@ -62,6 +62,7 @@ import io.smallrye.jwt.util.ResourceUtils; import io.smallrye.mutiny.Uni; import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpHeaders; import io.vertx.core.json.JsonObject; import io.vertx.core.net.KeyStoreOptions; import io.vertx.core.net.ProxyOptions; @@ -74,6 +75,8 @@ public class OidcCommonUtils { public static final Duration CONNECTION_BACKOFF_DURATION = Duration.ofSeconds(2); + public static final String LOCATION_RESPONSE_HEADER = String.valueOf(HttpHeaders.LOCATION); + public static final String COOKIE_REQUEST_HEADER = String.valueOf(HttpHeaders.COOKIE); static final byte AMP = '&'; static final byte EQ = '='; @@ -501,15 +504,65 @@ public static Predicate oidcEndpointNotAvailable() { || (t instanceof OidcEndpointAccessException && ((OidcEndpointAccessException) t).getErrorStatus() == 404)); } + public static Predicate validOidcClientRedirect(String originalUri) { + return t -> (t instanceof OidcClientRedirectException + && isValidOidcClientRedirectRequest((OidcClientRedirectException) t, originalUri)); + } + + private static boolean isValidOidcClientRedirectRequest(OidcClientRedirectException ex, + String originalUrl) { + if (!originalUrl.equals(ex.getLocation())) { + LOG.warnf("Redirect is only allowed to %s but redirect to %s is requested", + originalUrl, ex.getLocation()); + return false; + } + if (ex.getCookies().isEmpty()) { + LOG.warnf("Redirect is requested to %s but no cookies are set", originalUrl); + return false; + } + LOG.debugf("Single redirect to %s with cookies is approved", originalUrl); + return true; + } + public static Uni discoverMetadata(WebClient client, Map> requestFilters, OidcRequestContextProperties contextProperties, Map> responseFilters, String authServerUrl, long connectionDelayInMillisecs, Vertx vertx, boolean blockingDnsLookup) { final String discoveryUrl = getDiscoveryUri(authServerUrl); - HttpRequest request = client.getAbs(discoveryUrl); final OidcRequestContextProperties requestProps = requestFilters.isEmpty() ? null : getDiscoveryRequestProps(contextProperties, discoveryUrl); + + return doDiscoverMetadata(client, requestFilters, contextProperties, responseFilters, discoveryUrl, + connectionDelayInMillisecs, vertx, blockingDnsLookup, List.of()) + .onFailure(validOidcClientRedirect(discoveryUrl)) + .recoverWithUni( + new Function>() { + @Override + public Uni apply(Throwable t) { + OidcClientRedirectException ex = (OidcClientRedirectException) t; + return doDiscoverMetadata(client, requestFilters, requestProps, responseFilters, + discoveryUrl, connectionDelayInMillisecs, vertx, blockingDnsLookup, ex.getCookies()); + } + }) + .onFailure().transform(t -> { + LOG.warn("OIDC Server is not available:", t.getCause() != null ? t.getCause() : t); + // don't wrap it to avoid information leak + return new RuntimeException("OIDC Server is not available"); + }); + + } + + public static Uni doDiscoverMetadata(WebClient client, + Map> requestFilters, + OidcRequestContextProperties requestProps, Map> responseFilters, + String discoveryUrl, + long connectionDelayInMillisecs, Vertx vertx, boolean blockingDnsLookup, + List cookies) { + HttpRequest request = client.getAbs(discoveryUrl); + if (!cookies.isEmpty()) { + request.putHeader(COOKIE_REQUEST_HEADER, cookies); + } if (!requestFilters.isEmpty()) { OidcRequestContext context = new OidcRequestContext(request, null, requestProps); for (OidcRequestFilter filter : getMatchingOidcRequestFilters(requestFilters, OidcEndpoint.Type.DISCOVERY)) { @@ -523,8 +576,10 @@ public static Uni discoverMetadata(WebClient client, if (resp.statusCode() == 200) { return buffer.toJsonObject(); + } else if (resp.statusCode() == 302) { + throw createOidcClientRedirectException(resp); } else { - String errorMessage = buffer.toString(); + String errorMessage = buffer != null ? buffer.toString() : null; if (errorMessage != null && !errorMessage.isEmpty()) { LOG.warnf("Discovery request %s has failed, status code: %d, error message: %s", discoveryUrl, resp.statusCode(), errorMessage); @@ -536,12 +591,12 @@ public static Uni discoverMetadata(WebClient client, }).onFailure(oidcEndpointNotAvailable()) .retry() .withBackOff(CONNECTION_BACKOFF_DURATION, CONNECTION_BACKOFF_DURATION) - .expireIn(connectionDelayInMillisecs) - .onFailure().transform(t -> { - LOG.warn("OIDC Server is not available:", t.getCause() != null ? t.getCause() : t); - // don't wrap it to avoid information leak - return new RuntimeException("OIDC Server is not available"); - }); + .expireIn(connectionDelayInMillisecs); + } + + public static OidcClientRedirectException createOidcClientRedirectException(HttpResponse resp) { + LOG.debug("OIDC client redirect is requested"); + return new OidcClientRedirectException(resp.getHeader(LOCATION_RESPONSE_HEADER), resp.cookies()); } private static OidcRequestContextProperties getDiscoveryRequestProps( diff --git a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java index 41e2e211b3b40..c818e0d28549e 100644 --- a/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java +++ b/extensions/oidc/runtime/src/main/java/io/quarkus/oidc/runtime/OidcProviderClient.java @@ -6,6 +6,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; import org.jboss.logging.Logger; @@ -20,6 +21,7 @@ import io.quarkus.oidc.common.OidcRequestFilter.OidcRequestContext; import io.quarkus.oidc.common.OidcResponseFilter; import io.quarkus.oidc.common.runtime.OidcClientCommonConfig.Credentials.Secret.Method; +import io.quarkus.oidc.common.runtime.OidcClientRedirectException; import io.quarkus.oidc.common.runtime.OidcCommonUtils; import io.quarkus.oidc.common.runtime.OidcConstants; import io.smallrye.mutiny.Uni; @@ -87,34 +89,69 @@ public OidcConfigurationMetadata getMetadata() { } public Uni getJsonWebKeySet(OidcRequestContextProperties contextProperties) { - OidcRequestContextProperties requestProps = getRequestProps(contextProperties); + final OidcRequestContextProperties requestProps = getRequestProps(contextProperties); + return doGetJsonWebKeySet(requestProps, List.of()) + .onFailure(OidcCommonUtils.validOidcClientRedirect(metadata.getJsonWebKeySetUri())) + .recoverWithUni( + new Function>() { + @Override + public Uni apply(Throwable t) { + OidcClientRedirectException ex = (OidcClientRedirectException) t; + return doGetJsonWebKeySet(requestProps, ex.getCookies()); + } + }); + } + + private Uni doGetJsonWebKeySet(OidcRequestContextProperties requestProps, List cookies) { + LOG.debugf("Get verification JWT Key Set at %s", metadata.getJsonWebKeySetUri()); + HttpRequest request = client.getAbs(metadata.getJsonWebKeySetUri()); + if (!cookies.isEmpty()) { + request.putHeader(OidcCommonUtils.COOKIE_REQUEST_HEADER, cookies); + } return OidcCommonUtils .sendRequest(vertx, - filterHttpRequest(requestProps, OidcEndpoint.Type.JWKS, client.getAbs(metadata.getJsonWebKeySetUri()), - null, - contextProperties), + filterHttpRequest(requestProps, OidcEndpoint.Type.JWKS, request, null), oidcConfig.useBlockingDnsLookup) .onItem() .transform(resp -> getJsonWebKeySet(requestProps, resp)); } - public Uni getUserInfo(String token) { + public Uni getUserInfo(final String token) { + + final OidcRequestContextProperties requestProps = getRequestProps(null, null); + + return doGetUserInfo(requestProps, token, List.of()) + .onFailure(OidcCommonUtils.validOidcClientRedirect(metadata.getUserInfoUri())) + .recoverWithUni( + new Function>() { + @Override + public Uni apply(Throwable t) { + OidcClientRedirectException ex = (OidcClientRedirectException) t; + return doGetUserInfo(requestProps, token, ex.getCookies()); + } + }); + } + + private Uni doGetUserInfo(OidcRequestContextProperties requestProps, String token, List cookies) { LOG.debugf("Get UserInfo on: %s auth: %s", metadata.getUserInfoUri(), OidcConstants.BEARER_SCHEME + " " + token); - OidcRequestContextProperties requestProps = getRequestProps(null, null); + + HttpRequest request = client.getAbs(metadata.getUserInfoUri()); + if (!cookies.isEmpty()) { + request.putHeader(OidcCommonUtils.COOKIE_REQUEST_HEADER, cookies); + } return OidcCommonUtils .sendRequest(vertx, - filterHttpRequest(requestProps, OidcEndpoint.Type.USERINFO, client.getAbs(metadata.getUserInfoUri()), - null, null) + filterHttpRequest(requestProps, OidcEndpoint.Type.USERINFO, request, null) .putHeader(AUTHORIZATION_HEADER, OidcConstants.BEARER_SCHEME + " " + token), oidcConfig.useBlockingDnsLookup) .onItem().transform(resp -> getUserInfo(requestProps, resp)); } - public Uni introspectToken(String token) { - MultiMap introspectionParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap()); + public Uni introspectToken(final String token) { + final MultiMap introspectionParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap()); introspectionParams.add(OidcConstants.INTROSPECTION_TOKEN, token); introspectionParams.add(OidcConstants.INTROSPECTION_TOKEN_TYPE_HINT, OidcConstants.ACCESS_TOKEN_VALUE); - OidcRequestContextProperties requestProps = getRequestProps(null, null); + final OidcRequestContextProperties requestProps = getRequestProps(null, null); return getHttpResponse(requestProps, metadata.getIntrospectionUri(), introspectionParams, true) .transform(resp -> getTokenIntrospection(requestProps, resp)); } @@ -128,7 +165,7 @@ public OidcTenantConfig getOidcConfig() { } public Uni getAuthorizationCodeTokens(String code, String redirectUri, String codeVerifier) { - MultiMap codeGrantParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap()); + final MultiMap codeGrantParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap()); codeGrantParams.add(OidcConstants.GRANT_TYPE, OidcConstants.AUTHORIZATION_CODE); codeGrantParams.add(OidcConstants.CODE_FLOW_CODE, code); codeGrantParams.add(OidcConstants.CODE_FLOW_REDIRECT_URI, redirectUri); @@ -138,16 +175,16 @@ public Uni getAuthorizationCodeTokens(String code, Stri if (oidcConfig.codeGrant.extraParams != null) { codeGrantParams.addAll(oidcConfig.codeGrant.extraParams); } - OidcRequestContextProperties requestProps = getRequestProps(OidcConstants.AUTHORIZATION_CODE); + final OidcRequestContextProperties requestProps = getRequestProps(OidcConstants.AUTHORIZATION_CODE); return getHttpResponse(requestProps, metadata.getTokenUri(), codeGrantParams, false) .transform(resp -> getAuthorizationCodeTokens(requestProps, resp)); } public Uni refreshAuthorizationCodeTokens(String refreshToken) { - MultiMap refreshGrantParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap()); + final MultiMap refreshGrantParams = new MultiMap(io.vertx.core.MultiMap.caseInsensitiveMultiMap()); refreshGrantParams.add(OidcConstants.GRANT_TYPE, OidcConstants.REFRESH_TOKEN_GRANT); refreshGrantParams.add(OidcConstants.REFRESH_TOKEN_VALUE, refreshToken); - OidcRequestContextProperties requestProps = getRequestProps(OidcConstants.REFRESH_TOKEN_GRANT); + final OidcRequestContextProperties requestProps = getRequestProps(OidcConstants.REFRESH_TOKEN_GRANT); return getHttpResponse(requestProps, metadata.getTokenUri(), refreshGrantParams, false) .transform(resp -> getAuthorizationCodeTokens(requestProps, resp)); } @@ -205,7 +242,7 @@ private UniOnItem> getHttpResponse(OidcRequestContextProper // Retry up to three times with a one-second delay between the retries if the connection is closed. OidcEndpoint.Type endpoint = introspect ? OidcEndpoint.Type.INTROSPECTION : OidcEndpoint.Type.TOKEN; - Uni> response = filterHttpRequest(requestProps, endpoint, request, buffer, null).sendBuffer(buffer) + Uni> response = filterHttpRequest(requestProps, endpoint, request, buffer).sendBuffer(buffer) .onFailure(ConnectException.class) .retry() .atMost(oidcConfig.connectionRetryCount).onFailure().transform(t -> t.getCause()); @@ -245,6 +282,8 @@ private JsonObject getJsonObject(OidcRequestContextProperties requestProps, Stri if (resp.statusCode() == 200) { LOG.debugf("Request succeeded: %s", resp.bodyAsJsonObject()); return buffer.toJsonObject(); + } else if (resp.statusCode() == 302) { + throw OidcCommonUtils.createOidcClientRedirectException(resp); } else { throw responseException(requestUri, resp, buffer); } @@ -257,6 +296,8 @@ private String getString(final OidcRequestContextProperties requestProps, String if (resp.statusCode() == 200) { LOG.debugf("Request succeeded: %s", resp.bodyAsString()); return buffer.toString(); + } else if (resp.statusCode() == 302) { + throw OidcCommonUtils.createOidcClientRedirectException(resp); } else { throw responseException(requestUri, resp, buffer); } @@ -284,8 +325,7 @@ public Key getClientJwtKey() { } private HttpRequest filterHttpRequest(OidcRequestContextProperties requestProps, OidcEndpoint.Type endpointType, - HttpRequest request, Buffer body, - OidcRequestContextProperties contextProperties) { + HttpRequest request, Buffer body) { if (!requestFilters.isEmpty()) { OidcRequestContext context = new OidcRequestContext(request, body, requestProps); for (OidcRequestFilter filter : OidcCommonUtils.getMatchingOidcRequestFilters(requestFilters, endpointType)) { 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 d06efc38e43af..ff8b8b326c71c 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 @@ -533,7 +533,7 @@ protected static Uni createOidcClientUni(OidcTenantConfig oi String authServerUriString = OidcCommonUtils.getAuthServerUrl(oidcConfig); WebClientOptions options = new WebClientOptions(); - + options.setFollowRedirects(oidcConfig.followRedirects); OidcCommonUtils.setHttpClientOptions(oidcConfig, options, tlsSupport.forConfig(oidcConfig.tls)); var mutinyVertx = new io.vertx.mutiny.core.Vertx(vertx); WebClient client = WebClient.create(mutinyVertx, options); diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/TenantRedirectLoop.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/TenantRedirectLoop.java new file mode 100644 index 0000000000000..8c5b556223ab0 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/TenantRedirectLoop.java @@ -0,0 +1,21 @@ +package io.quarkus.it.keycloak; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import io.quarkus.oidc.Tenant; +import io.quarkus.security.Authenticated; + +@Tenant("redirect-loop") +@Path("/api-redirect-loop") +@Authenticated +public class TenantRedirectLoop { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String getTenant() { + return "redirect-loop"; + } +} diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/TenantRedirectLoop2.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/TenantRedirectLoop2.java new file mode 100644 index 0000000000000..eab473760bc47 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/TenantRedirectLoop2.java @@ -0,0 +1,21 @@ +package io.quarkus.it.keycloak; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import io.quarkus.oidc.Tenant; +import io.quarkus.security.Authenticated; + +@Tenant("redirect-loop2") +@Path("/api-redirect-loop2") +@Authenticated +public class TenantRedirectLoop2 { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String getTenant() { + return "redirect-loop2"; + } +} diff --git a/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/TenantRedirectLoop3.java b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/TenantRedirectLoop3.java new file mode 100644 index 0000000000000..3abf43c761c59 --- /dev/null +++ b/integration-tests/oidc-wiremock/src/main/java/io/quarkus/it/keycloak/TenantRedirectLoop3.java @@ -0,0 +1,21 @@ +package io.quarkus.it.keycloak; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; + +import io.quarkus.oidc.Tenant; +import io.quarkus.security.Authenticated; + +@Tenant("redirect-loop3") +@Path("/api-redirect-loop3") +@Authenticated +public class TenantRedirectLoop3 { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String getTenant() { + return "redirect-loop3"; + } +} diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/AnnotationBasedTenantTest.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/AnnotationBasedTenantTest.java index 3e5fb78ce14fd..9c05debd5e48f 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/AnnotationBasedTenantTest.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/AnnotationBasedTenantTest.java @@ -34,6 +34,7 @@ public Map getConfigOverrides() { Map.entry("quarkus.oidc.hr.credentials.secret", "secret"), Map.entry("quarkus.oidc.hr.tenant-paths", "/api/tenant-echo/http-security-policy-applies-all-same"), Map.entry("quarkus.oidc.hr.token.audience", "http://hr.service"), + Map.entry("quarkus.oidc.hr.follow-redirects", "false"), Map.entry("quarkus.http.auth.policy.roles1.roles-allowed", "role1"), Map.entry("quarkus.http.auth.policy.roles2.roles-allowed", "role2"), Map.entry("quarkus.http.auth.policy.roles3.roles-allowed", "role3,role2"), @@ -68,7 +69,13 @@ public Map getConfigOverrides() { Map.entry("quarkus.http.auth.permission.tenant-annotation-applies-all.paths", "/api/tenant-echo/http-security-policy-applies-all-diff,/api/tenant-echo/http-security-policy-applies-all-same"), Map.entry("quarkus.http.auth.permission.tenant-annotation-applies-all.policy", "admin-role"), - Map.entry("quarkus.http.auth.policy.admin-role.roles-allowed", "admin")); + Map.entry("quarkus.http.auth.policy.admin-role.roles-allowed", "admin"), + Map.entry("quarkus.oidc.redirect-loop.auth-server-url", "http://localhost:8180/auth/realms/quarkus3/"), + Map.entry("quarkus.oidc.redirect-loop.follow-redirects", "false"), + Map.entry("quarkus.oidc.redirect-loop2.auth-server-url", "http://localhost:8180/auth/realms/quarkus4/"), + Map.entry("quarkus.oidc.redirect-loop2.follow-redirects", "false"), + Map.entry("quarkus.oidc.redirect-loop3.auth-server-url", "http://localhost:8180/auth/realms/quarkus5/"), + Map.entry("quarkus.oidc.redirect-loop3.follow-redirects", "false")); } @Override @@ -101,6 +108,18 @@ public void testClassLevelAnnotation() { .body(Matchers.equalTo(("tenant-id=hr, static.tenant.id=hr, name=alice, " + OidcUtils.TENANT_ID_SET_BY_ANNOTATION + "=hr"))); + RestAssured.given().auth().oauth2(token) + .when().get("/api-redirect-loop") + .then().statusCode(500); + + RestAssured.given().auth().oauth2(token) + .when().get("/api-redirect-loop2") + .then().statusCode(500); + + RestAssured.given().auth().oauth2(token) + .when().get("/api-redirect-loop3") + .then().statusCode(500); + } finally { server.stop(); } diff --git a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/WiremockTestResource.java b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/WiremockTestResource.java index 51e3b088fd874..e0596bae9875a 100644 --- a/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/WiremockTestResource.java +++ b/integration-tests/oidc-wiremock/src/test/java/io/quarkus/it/keycloak/WiremockTestResource.java @@ -40,6 +40,17 @@ public void start() { server.stubFor( get(urlEqualTo("/auth/realms/quarkus2/.well-known/openid-configuration")) + .withHeader("Filter", equalTo("OK")) + .withHeader("Cookie", absent()) + .willReturn(aResponse() + .withStatus(302) + .withHeader("Location", "http://localhost:" + port + + "/auth/realms/quarkus2/.well-known/openid-configuration") + .withHeader("Set-Cookie", "redirect=true; Path=/; Domain=some.domain.com"))); + + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus2/.well-known/openid-configuration")) + .withHeader("Cookie", equalTo("redirect=true")) .withHeader("Filter", equalTo("OK")) .withHeader("tenant-id", not(absent())) .willReturn(aResponse() @@ -68,6 +79,125 @@ public void start() { " ]\n" + "}"))); + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus3/.well-known/openid-configuration")) + .withHeader("Filter", equalTo("OK")) + .withHeader("Cookie", absent()) + .willReturn(aResponse() + .withStatus(302) + .withHeader("Location", "http://localhost:" + port + + "/auth/realms/quarkus3/.well-known/openid-configuration") + .withHeader("Set-Cookie", "redirect1=true; Path=/; Domain=some.domain.com"))); + + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus3/.well-known/openid-configuration")) + .withHeader("Filter", equalTo("OK")) + .withHeader("Cookie", equalTo("redirect1=true")) + .willReturn(aResponse() + .withStatus(302) + .withHeader("Location", "http://localhost:" + port + + "/auth/realms/quarkus3/.well-known/openid-configuration") + .withHeader("Set-Cookie", "redirect2=true; Path=/; Domain=some.domain.com"))); + + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus3/.well-known/openid-configuration")) + .withHeader("Cookie", equalTo("redirect2=true")) + .withHeader("Filter", equalTo("OK")) + .withHeader("tenant-id", not(absent())) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + (issuer == null ? "" : " \"" + ISSUER + "\": " + "\"" + issuer + "\",\n") + + " \"jwks_uri\": \"" + server.baseUrl() + + "/auth/realms/quarkus3/protocol/openid-connect/certs\"" + + "}"))); + + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus3/protocol/openid-connect/certs")) + .withHeader("Filter", equalTo("OK")) + .withHeader("tenant-id", not(absent())) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"keys\" : [\n" + + " {\n" + + " \"kid\": \"1\",\n" + + " \"kty\":\"RSA\",\n" + + " \"n\":\"iJw33l1eVAsGoRlSyo-FCimeOc-AaZbzQ2iESA3Nkuo3TFb1zIkmt0kzlnWVGt48dkaIl13Vdefh9hqw_r9yNF8xZqX1fp0PnCWc5M_TX_ht5fm9y0TpbiVmsjeRMWZn4jr3DsFouxQ9aBXUJiu26V0vd2vrECeeAreFT4mtoHY13D2WVeJvboc5mEJcp50JNhxRCJ5UkY8jR_wfUk2Tzz4-fAj5xQaBccXnqJMu_1C6MjoCEiB7G1d13bVPReIeAGRKVJIF6ogoCN8JbrOhc_48lT4uyjbgnd24beatuKWodmWYhactFobRGYo5551cgMe8BoxpVQ4to30cGA0qjQ\",\n" + + + " \"e\":\"AQAB\"\n" + + " }\n" + + " ]\n" + + "}"))); + + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus4/.well-known/openid-configuration")) + .withHeader("Filter", equalTo("OK")) + .withHeader("Cookie", absent()) + .willReturn(aResponse() + .withStatus(302) + .withHeader("Location", "http://localhost:" + port + + "/auth/realms/quarkus4/.well-known/different-openid-configuration") + .withHeader("Set-Cookie", "redirect=true; Path=/; Domain=some.domain.com"))); + + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus4/.well-known/different-openid-configuration")) + .withHeader("Cookie", equalTo("redirect=true")) + .withHeader("Filter", equalTo("OK")) + .withHeader("tenant-id", not(absent())) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + (issuer == null ? "" : " \"" + ISSUER + "\": " + "\"" + issuer + "\",\n") + + " \"jwks_uri\": \"" + server.baseUrl() + + "/auth/realms/quarkus4/protocol/openid-connect/certs\"" + + "}"))); + + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus4/protocol/openid-connect/certs")) + .withHeader("Filter", equalTo("OK")) + .withHeader("tenant-id", not(absent())) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"keys\" : [\n" + + " {\n" + + " \"kid\": \"1\",\n" + + " \"kty\":\"RSA\",\n" + + " \"n\":\"iJw33l1eVAsGoRlSyo-FCimeOc-AaZbzQ2iESA3Nkuo3TFb1zIkmt0kzlnWVGt48dkaIl13Vdefh9hqw_r9yNF8xZqX1fp0PnCWc5M_TX_ht5fm9y0TpbiVmsjeRMWZn4jr3DsFouxQ9aBXUJiu26V0vd2vrECeeAreFT4mtoHY13D2WVeJvboc5mEJcp50JNhxRCJ5UkY8jR_wfUk2Tzz4-fAj5xQaBccXnqJMu_1C6MjoCEiB7G1d13bVPReIeAGRKVJIF6ogoCN8JbrOhc_48lT4uyjbgnd24beatuKWodmWYhactFobRGYo5551cgMe8BoxpVQ4to30cGA0qjQ\",\n" + + + " \"e\":\"AQAB\"\n" + + " }\n" + + " ]\n" + + "}"))); + + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus5/.well-known/openid-configuration")) + .withHeader("Filter", equalTo("OK")) + .withHeader("Cookie", absent()) + .willReturn(aResponse() + .withStatus(302) + .withHeader("Location", "http://localhost:" + port + + "/auth/realms/quarkus5/.well-known/openid-configuration"))); + + server.stubFor( + get(urlEqualTo("/auth/realms/quarkus5/protocol/openid-connect/certs")) + .withHeader("Filter", equalTo("OK")) + .withHeader("tenant-id", not(absent())) + .willReturn(aResponse() + .withHeader("Content-Type", "application/json") + .withBody("{\n" + + " \"keys\" : [\n" + + " {\n" + + " \"kid\": \"1\",\n" + + " \"kty\":\"RSA\",\n" + + " \"n\":\"iJw33l1eVAsGoRlSyo-FCimeOc-AaZbzQ2iESA3Nkuo3TFb1zIkmt0kzlnWVGt48dkaIl13Vdefh9hqw_r9yNF8xZqX1fp0PnCWc5M_TX_ht5fm9y0TpbiVmsjeRMWZn4jr3DsFouxQ9aBXUJiu26V0vd2vrECeeAreFT4mtoHY13D2WVeJvboc5mEJcp50JNhxRCJ5UkY8jR_wfUk2Tzz4-fAj5xQaBccXnqJMu_1C6MjoCEiB7G1d13bVPReIeAGRKVJIF6ogoCN8JbrOhc_48lT4uyjbgnd24beatuKWodmWYhactFobRGYo5551cgMe8BoxpVQ4to30cGA0qjQ\",\n" + + + " \"e\":\"AQAB\"\n" + + " }\n" + + " ]\n" + + "}"))); + LOG.infof("Keycloak started in mock mode: %s", server.baseUrl()); }