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 92b8adf89be69..8d8cddbe73cc2 100644 --- a/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc +++ b/docs/src/main/asciidoc/security-openid-connect-web-authentication.adoc @@ -1278,6 +1278,21 @@ You can add more properties to it with `quarkus.oidc.authentication.extra-params quarkus.oidc.authentication.extra-params.response_mode=query ---- +=== Customize authentication error response + +If the user authentication has failed at the OpenId Connect Authorization endpoint, for example, due to an invalid scope or other invalid parameters included in the redirect to the provider, then the provider will redirect the user back to Quarkus not with the `code` but `error` and `error_description` parameters. + +In such cases HTTP `401` will be returned by default. However, you can instead request that a custom public error endpoint is called in order to return a user friendly HTML error page. Use `quarkus.oidc.authentication.error-path`, for example: + +[source,properties] +---- +quarkus.oidc.authentication.error-path=/error +---- + +It has to start fron a forward slash and be relative to the current endpoint's base URI. For example, if it is set as '/error' and the current request URI is `https://localhost:8080/callback?error=invalid_scope` then a final redirect will be made to `https://localhost:8080/error?error=invalid_scope`. + +It is important that this error endpoint is a public resource to avoid the user redirected to this page be authenticated again. + == Configuration Reference include::{generated-dir}/config/quarkus-oidc.adoc[opts=optional] diff --git a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java index 8251c56579b7e..bdaa51b1a4bc8 100644 --- a/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java +++ b/extensions/oidc-common/runtime/src/main/java/io/quarkus/oidc/common/runtime/OidcConstants.java @@ -42,6 +42,8 @@ public final class OidcConstants { public static final String AUTHORIZATION_CODE = "authorization_code"; public static final String CODE_FLOW_RESPONSE_TYPE = "response_type"; public static final String CODE_FLOW_CODE = "code"; + public static final String CODE_FLOW_ERROR = "error"; + public static final String CODE_FLOW_ERROR_DESCRIPTION = "error_description"; public static final String CODE_FLOW_STATE = "state"; public static final String CODE_FLOW_REDIRECT_URI = "redirect_uri"; 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 a1570bec62e95..e093d047523f3 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 @@ -462,6 +462,23 @@ public static class Authentication { @ConfigItem(defaultValue = "true") public boolean removeRedirectParameters = true; + /** + * Relative path to the public endpoint which will process the error response from the OIDC authorization endpoint. + * If the user authentication has failed then the OIDC provider will return an 'error' and an optional + * 'error_description' + * parameters, instead of the expected authorization 'code'. + * + * If this property is set then the user will be redirected to the endpoint which can return a user friendly + * error description page. It has to start from a forward slash and will be appended to the request URI's host and port. + * For example, if it is set as '/error' and the current request URI is + * 'https://localhost:8080/callback?error=invalid_scope' + * then a redirect will be made to 'https://localhost:8080/error?error=invalid_scope'. + * + * If this property is not set then HTTP 401 status will be returned in case of the user authentication failure. + */ + @ConfigItem + public Optional errorPath = Optional.empty(); + /** * Both ID and access tokens are fetched from the OIDC provider as part of the authorization code flow. * ID token is always verified on every user request as the primary token which is used @@ -572,6 +589,14 @@ public static class Authentication { @ConfigItem(defaultValueDocumentation = "true") public Optional idTokenRequired = Optional.empty(); + public Optional getErrorPath() { + return errorPath; + } + + public void setErrorPath(String errorPath) { + this.errorPath = Optional.of(errorPath); + } + public boolean isJavaScriptAutoRedirect() { return javaScriptAutoRedirect; } 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 92cd20051b47c..205c5a2114019 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 @@ -99,6 +99,28 @@ public Uni apply(TenantConfigContext tenantContext) { parsedStateCookieValue); } }); + } else if (context.request().getParam(OidcConstants.CODE_FLOW_ERROR) != null) { + OidcUtils.removeCookie(context, oidcTenantConfig, stateCookie.getName()); + String error = context.request().getParam(OidcConstants.CODE_FLOW_ERROR); + String errorDescription = context.request().getParam(OidcConstants.CODE_FLOW_ERROR_DESCRIPTION); + + LOG.debugf("Authentication has failed, error: %s, description: %s", error, errorDescription); + + if (oidcTenantConfig.authentication.errorPath.isPresent()) { + URI absoluteUri = URI.create(context.request().absoluteURI()); + + StringBuilder errorUri = new StringBuilder(buildUri(context, + isForceHttps(oidcTenantConfig), + absoluteUri.getAuthority(), + oidcTenantConfig.authentication.errorPath.get())); + errorUri.append('?').append(absoluteUri.getRawQuery()); + + String finalErrorUri = errorUri.toString(); + LOG.debugf("Error URI: %s", finalErrorUri); + return Uni.createFrom().failure(new AuthenticationRedirectException(finalErrorUri)); + } else { + return Uni.createFrom().failure(new AuthenticationCompletionException()); + } } else { LOG.debug("State cookie is present but neither 'code' nor 'error' query parameter is returned"); OidcUtils.removeCookie(context, oidcTenantConfig, stateCookie.getName()); @@ -232,12 +254,10 @@ public Uni getChallengeInternal(RoutingContext context, TenantCon public Uni apply(Void t) { if (context.get(NO_OIDC_COOKIES_AVAILABLE) != null - && context.request().getParam(OidcConstants.CODE_FLOW_CODE) != null && isRedirectFromProvider(context, configContext)) { LOG.debug( - "The state cookie is missing but the 'code' is available, authentication has failed"); + "The state cookie is missing after the redirect from OpenId Connect Provider, authentication has failed"); return Uni.createFrom().item(new ChallengeData(401, "WWW-Authenticate", "OIDC")); - } if (!shouldAutoRedirect(configContext, context)) { @@ -269,7 +289,7 @@ && isRedirectFromProvider(context, configContext)) { // redirect_uri String redirectPath = getRedirectPath(configContext, context); - String redirectUriParam = buildUri(context, isForceHttps(configContext), redirectPath); + String redirectUriParam = buildUri(context, isForceHttps(configContext.oidcConfig), redirectPath); LOG.debugf("Authentication request redirect_uri parameter: %s", redirectUriParam); codeFlowParams.append(AMP).append(OidcConstants.CODE_FLOW_REDIRECT_URI).append(EQ) @@ -293,6 +313,10 @@ && isRedirectFromProvider(context, configContext)) { } private boolean isRedirectFromProvider(RoutingContext context, TenantConfigContext configContext) { + // The referrer check is the best effort at attempting to avoid the redirect loop after + // the user has authenticated at the OpenId Connect Provider page but the state cookie has been lost + // during the redirect back to Quarkus. + String referer = context.request().getHeader(HttpHeaders.REFERER); return referer != null && referer.startsWith(configContext.provider.getMetadata().getAuthorizationUri()); } @@ -369,7 +393,7 @@ public SecurityIdentity apply(SecurityIdentity identity) { URI absoluteUri = URI.create(context.request().absoluteURI()); StringBuilder finalUriWithoutQuery = new StringBuilder(buildUri(context, - isForceHttps(configContext), + isForceHttps(configContext.oidcConfig), absoluteUri.getAuthority(), (finalUserPath != null ? finalUserPath : absoluteUri.getRawPath()))); @@ -618,7 +642,7 @@ private Uni getCodeFlowTokensUni(RoutingContext context // 'redirect_uri': typically it must match the 'redirect_uri' query parameter which was used during the code request. String redirectPath = getRedirectPath(configContext, context); - String redirectUriParam = buildUri(context, isForceHttps(configContext), redirectPath); + String redirectUriParam = buildUri(context, isForceHttps(configContext.oidcConfig), redirectPath); LOG.debugf("Token request redirect_uri parameter: %s", redirectUriParam); return configContext.provider.getCodeFlowTokens(code, redirectUriParam); @@ -636,7 +660,8 @@ private String buildLogoutRedirectUri(TenantConfigContext configContext, String if (configContext.oidcConfig.logout.postLogoutPath.isPresent()) { logoutUri.append(AMP).append(configContext.oidcConfig.logout.getPostLogoutUriParam()).append(EQ).append( - buildUri(context, isForceHttps(configContext), configContext.oidcConfig.logout.postLogoutPath.get())); + buildUri(context, isForceHttps(configContext.oidcConfig), + configContext.oidcConfig.logout.postLogoutPath.get())); logoutUri.append(AMP).append(OidcConstants.LOGOUT_STATE).append(EQ) .append(generatePostLogoutState(context, configContext)); } @@ -654,8 +679,8 @@ private static void addExtraParamsToUri(StringBuilder builder, Map buildLogoutRedirectUriUni(RoutingContext context, TenantConfigContext configContext, 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 5177d547dae55..bca5b831aed75 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 @@ -10,7 +10,6 @@ import io.vertx.ext.web.RoutingContext; @Path("/tenant-https") -@Authenticated public class TenantHttps { @Inject @@ -19,13 +18,21 @@ public class TenantHttps { RoutingContext routingContext; @GET + @Authenticated public String getTenant() { return session.getTenantId() + (routingContext.get("reauthenticated") != null ? ":reauthenticated" : ""); } @GET @Path("query") + @Authenticated public String getTenantWithQuery(@QueryParam("code") String value) { return getTenant() + "?code=" + value; } + + @GET + @Path("error") + public String getError(@QueryParam("error") String error, @QueryParam("error_description") String errorDescription) { + return "error: " + error + ", error_description: " + errorDescription; + } } 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 95252fcad23e8..c5362ae070bff 100644 --- a/integration-tests/oidc-code-flow/src/main/resources/application.properties +++ b/integration-tests/oidc-code-flow/src/main/resources/application.properties @@ -96,7 +96,7 @@ quarkus.oidc.tenant-https.authentication.extra-params.max-age=60 quarkus.oidc.tenant-https.application-type=web-app quarkus.oidc.tenant-https.authentication.force-redirect-https-scheme=true quarkus.oidc.tenant-https.authentication.cookie-suffix=test -quarkus.oidc.tenant-https.authentication.redirect-without-state-cookie=true +quarkus.oidc.tenant-https.authentication.error-path=/tenant-https/error quarkus.oidc.tenant-javascript.auth-server-url=${keycloak.url}/realms/quarkus quarkus.oidc.tenant-javascript.client-id=quarkus-app 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 a91f2f632fc50..ada9c0f3fdb0c 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 @@ -87,6 +87,68 @@ public void testCodeFlowNoConsent() throws IOException { } } + @Test + public void testCodeFlowScopeError() throws IOException { + try (final WebClient webClient = createWebClient()) { + webClient.getOptions().setRedirectEnabled(false); + WebResponse webResponse = webClient + .loadWebResponse( + new WebRequest(URI.create("http://localhost:8081/index.html").toURL())); + String keycloakUrl = webResponse.getResponseHeaderValue("location"); + + // replace scope + keycloakUrl = keycloakUrl.replace("scope=openid+profile+email+phone", "scope=unknown"); + + // response from keycloak + webResponse = webClient.loadWebResponse(new WebRequest(URI.create(keycloakUrl).toURL())); + + String endpointLocation = webResponse.getResponseHeaderValue("location"); + URI endpointLocationUri = URI.create(endpointLocation); + + // response from Quarkus + webResponse = webClient.loadWebResponse(new WebRequest(endpointLocationUri.toURL())); + assertEquals(401, webResponse.getStatusCode()); + webClient.getCookieManager().clearCookies(); + } + } + + @Test + public void testCodeFlowScopeErrorWithErrorPage() throws IOException { + try (final WebClient webClient = createWebClient()) { + webClient.getOptions().setRedirectEnabled(false); + + WebResponse webResponse = webClient + .loadWebResponse( + new WebRequest(URI.create("http://localhost:8081/tenant-https/query?code=b").toURL())); + String keycloakUrl = webResponse.getResponseHeaderValue("location"); + + // replace scope + keycloakUrl = keycloakUrl.replace("scope=openid+profile+email+phone", "scope=unknown"); + + // response from keycloak + webResponse = webClient.loadWebResponse(new WebRequest(URI.create(keycloakUrl).toURL())); + + // This is a redirect from the OIDC server to the endpoint + String endpointLocation = webResponse.getResponseHeaderValue("location"); + assertTrue(endpointLocation.startsWith("https")); + endpointLocation = "http" + endpointLocation.substring(5); + URI endpointLocationUri = URI.create(endpointLocation); + + webResponse = webClient.loadWebResponse(new WebRequest(endpointLocationUri.toURL())); + + // This is a redirect from quarkus-oidc to the error page + String endpointErrorLocation = webResponse.getResponseHeaderValue("location"); + assertTrue(endpointErrorLocation.startsWith("https")); + + endpointErrorLocation = "http" + endpointErrorLocation.substring(5); + + HtmlPage page = webClient.getPage(URI.create(endpointErrorLocation).toURL()); + assertEquals("error: invalid_scope, error_description: Invalid scopes: unknown profile email phone", + page.getBody().asText()); + webClient.getCookieManager().clearCookies(); + } + } + @Test public void testCodeFlowForceHttpsRedirectUri() throws IOException { try (final WebClient webClient = createWebClient()) {