Skip to content

Commit

Permalink
Merge pull request #23451 from sberyozkin/oidc_web_app_error_path
Browse files Browse the repository at this point in the history
Add OIDC error-path property
  • Loading branch information
sberyozkin authored Feb 10, 2022
2 parents 98e3a0b + 8f819a3 commit dd916b9
Show file tree
Hide file tree
Showing 7 changed files with 147 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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
Expand Down Expand Up @@ -572,6 +589,14 @@ public static class Authentication {
@ConfigItem(defaultValueDocumentation = "true")
public Optional<Boolean> idTokenRequired = Optional.empty();

public Optional<String> getErrorPath() {
return errorPath;
}

public void setErrorPath(String errorPath) {
this.errorPath = Optional.of(errorPath);
}

public boolean isJavaScriptAutoRedirect() {
return javaScriptAutoRedirect;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,28 @@ public Uni<SecurityIdentity> 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());
Expand Down Expand Up @@ -232,12 +254,10 @@ public Uni<ChallengeData> getChallengeInternal(RoutingContext context, TenantCon
public Uni<ChallengeData> 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)) {
Expand Down Expand Up @@ -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)
Expand All @@ -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());
}
Expand Down Expand Up @@ -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())));
Expand Down Expand Up @@ -618,7 +642,7 @@ private Uni<AuthorizationCodeTokens> 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);
Expand All @@ -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));
}
Expand All @@ -654,8 +679,8 @@ private static void addExtraParamsToUri(StringBuilder builder, Map<String, Strin
}
}

private boolean isForceHttps(TenantConfigContext configContext) {
return configContext.oidcConfig.authentication.forceRedirectHttpsScheme.orElse(false);
private boolean isForceHttps(OidcTenantConfig oidcConfig) {
return oidcConfig.authentication.forceRedirectHttpsScheme.orElse(false);
}

private Uni<Void> buildLogoutRedirectUriUni(RoutingContext context, TenantConfigContext configContext,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
import io.vertx.ext.web.RoutingContext;

@Path("/tenant-https")
@Authenticated
public class TenantHttps {

@Inject
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down

0 comments on commit dd916b9

Please sign in to comment.